diff --git a/src/features/community.rs b/src/features/community.rs new file mode 100644 index 000000000..e79fbb988 --- /dev/null +++ b/src/features/community.rs @@ -0,0 +1,402 @@ +//! Community feature. +//! +//! Communities are parent groups that contain linked subgroups. +//! Uses the `w:g2` IQ namespace for mutations and MEX (GraphQL) for metadata queries. + +use crate::client::Client; +use crate::features::groups::GroupMetadata; +use crate::features::groups::GroupParticipant; +use crate::features::mex::{MexError, MexRequest}; +use log::warn; +use serde_json::json; +use wacore::iq::community::mex_docs; +use wacore::iq::groups::{ + DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateIq, GroupCreateOptions, + JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq, +}; +use wacore_binary::jid::Jid; + +// Types + +/// Classification of a group within the community hierarchy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupType { + /// Regular standalone group (not part of a community). + Default, + /// Community parent group. + Community, + /// A subgroup linked to a community. + LinkedSubgroup, + /// The default announcement subgroup of a community. + LinkedAnnouncementGroup, + /// The general chat subgroup of a community. + LinkedGeneralGroup, +} + +/// Options for creating a new community. +#[derive(Debug, Clone)] +pub struct CreateCommunityOptions { + pub name: String, + pub description: Option, + /// Whether the community is closed (requires approval to join). + pub closed: bool, + /// Allow non-admin members to create subgroups. + pub allow_non_admin_sub_group_creation: bool, + /// Create a general chat subgroup alongside the community. + pub create_general_chat: bool, +} + +impl CreateCommunityOptions { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + description: None, + closed: false, + allow_non_admin_sub_group_creation: false, + create_general_chat: true, + } + } +} + +/// Result of creating a community. +#[derive(Debug, Clone)] +pub struct CreateCommunityResult { + /// JID of the created community parent group. + pub gid: Jid, +} + +/// A subgroup within a community. +#[derive(Debug, Clone)] +pub struct CommunitySubgroup { + pub id: Jid, + pub subject: String, + pub participant_count: Option, + pub is_default_sub_group: bool, + pub is_general_chat: bool, +} + +/// Result of linking subgroups to a community. +#[derive(Debug, Clone)] +pub struct LinkSubgroupsResult { + pub linked_jids: Vec, + pub failed_groups: Vec<(Jid, u32)>, +} + +/// Result of unlinking subgroups from a community. +#[derive(Debug, Clone)] +pub struct UnlinkSubgroupsResult { + pub unlinked_jids: Vec, + pub failed_groups: Vec<(Jid, u32)>, +} + +/// Determine the group type from metadata fields. +pub fn group_type(metadata: &GroupMetadata) -> GroupType { + if metadata.is_default_sub_group { + GroupType::LinkedAnnouncementGroup + } else if metadata.is_general_chat { + GroupType::LinkedGeneralGroup + } else if metadata.parent_group_jid.is_some() { + GroupType::LinkedSubgroup + } else if metadata.is_parent_group { + GroupType::Community + } else { + GroupType::Default + } +} + +// Feature handle + +pub struct Community<'a> { + client: &'a Client, +} + +impl<'a> Community<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Create a new community. + /// + /// If a description is provided, it is set via a follow-up IQ after creation + /// (the group create stanza does not support inline descriptions for communities). + pub async fn create( + &self, + options: CreateCommunityOptions, + ) -> Result { + let description = options.description.clone(); + + let create_options = GroupCreateOptions { + subject: options.name, + is_parent: true, + closed: options.closed, + allow_non_admin_sub_group_creation: options.allow_non_admin_sub_group_creation, + create_general_chat: options.create_general_chat, + ..Default::default() + }; + + let gid = self + .client + .execute(GroupCreateIq::new(create_options)) + .await?; + + // Set description via follow-up IQ if provided + if let Some(desc_text) = description + && let Ok(desc) = wacore::iq::groups::GroupDescription::new(&desc_text) + { + self.client + .groups() + .set_description(&gid, Some(desc), None) + .await?; + } + + Ok(CreateCommunityResult { gid }) + } + + /// Deactivate (delete) a community. Subgroups are unlinked but not deleted. + pub async fn deactivate(&self, community_jid: &Jid) -> Result<(), anyhow::Error> { + self.client + .execute(DeleteCommunityIq::new(community_jid)) + .await?; + Ok(()) + } + + /// Link existing groups as subgroups of a community. + pub async fn link_subgroups( + &self, + community_jid: &Jid, + subgroup_jids: &[Jid], + ) -> Result { + let response = self + .client + .execute(LinkSubgroupsIq::new(community_jid, subgroup_jids)) + .await?; + + let mut linked_jids = Vec::new(); + let mut failed_groups = Vec::new(); + + for group in response.groups { + if let Some(error) = group.error { + failed_groups.push((group.jid, error)); + } else { + linked_jids.push(group.jid); + } + } + + Ok(LinkSubgroupsResult { + linked_jids, + failed_groups, + }) + } + + /// Unlink subgroups from a community. + pub async fn unlink_subgroups( + &self, + community_jid: &Jid, + subgroup_jids: &[Jid], + remove_orphan_members: bool, + ) -> Result { + let response = self + .client + .execute(UnlinkSubgroupsIq::new( + community_jid, + subgroup_jids, + remove_orphan_members, + )) + .await?; + + let mut unlinked_jids = Vec::new(); + let mut failed_groups = Vec::new(); + + for group in response.groups { + if let Some(error) = group.error { + failed_groups.push((group.jid, error)); + } else { + unlinked_jids.push(group.jid); + } + } + + Ok(UnlinkSubgroupsResult { + unlinked_jids, + failed_groups, + }) + } + + /// Fetch all subgroups of a community via MEX (GraphQL). + pub async fn get_subgroups( + &self, + community_jid: &Jid, + ) -> Result, MexError> { + let response = self + .client + .mex() + .query(MexRequest { + doc_id: mex_docs::FETCH_ALL_SUBGROUPS, + variables: json!({ + "group_id": community_jid.to_string() + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?; + + let group_query = &data["xwa2_group_query_by_id"]; + let mut subgroups = Vec::new(); + + // Parse default subgroup + if let Some(default_sub) = group_query.get("default_sub_group") + && !default_sub.is_null() + && let Some(sg) = parse_subgroup_node(default_sub, true) + { + subgroups.push(sg); + } + + // Parse regular subgroups + if let Some(sub_groups) = group_query.get("sub_groups") + && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array()) + { + for edge in edges { + if let Some(node) = edge.get("node") + && let Some(sg) = parse_subgroup_node(node, false) + { + subgroups.push(sg); + } + } + } + + Ok(subgroups) + } + + /// Fetch participant counts per subgroup via MEX (GraphQL). + pub async fn get_subgroup_participant_counts( + &self, + community_jid: &Jid, + ) -> Result, MexError> { + let response = self + .client + .mex() + .query(MexRequest { + doc_id: mex_docs::FETCH_SUBGROUP_PARTICIPANT_COUNT, + variables: json!({ + "input": { + "group_jid": community_jid.to_string() + } + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?; + + let group_query = &data["xwa2_group_query_by_id"]; + let mut counts = Vec::new(); + + if let Some(sub_groups) = group_query.get("sub_groups") + && let Some(edges) = sub_groups.get("edges").and_then(|e| e.as_array()) + { + for edge in edges { + if let Some(node) = edge.get("node") { + let id_str = node["id"].as_str().unwrap_or_default(); + let count = node + .get("total_participants_count") + .or_else(|| node.get("participants_count")) + .and_then(|c| c.as_u64()) + .unwrap_or(0) as u32; + match id_str.parse::() { + Ok(jid) => counts.push((jid, count)), + Err(_) => warn!( + "community: skipping subgroup with unparseable id: {:?}", + id_str + ), + } + } + } + } + + Ok(counts) + } + + /// Query a linked subgroup's metadata from the parent community. + pub async fn query_linked_group( + &self, + community_jid: &Jid, + subgroup_jid: &Jid, + ) -> Result { + let response = self + .client + .execute(QueryLinkedGroupIq::new(community_jid, subgroup_jid)) + .await?; + Ok(GroupMetadata::from(response)) + } + + /// Join a linked subgroup via the parent community. + pub async fn join_subgroup( + &self, + community_jid: &Jid, + subgroup_jid: &Jid, + ) -> Result { + let response = self + .client + .execute(JoinLinkedGroupIq::new(community_jid, subgroup_jid)) + .await?; + Ok(GroupMetadata::from(response)) + } + + /// Get all participants across all linked groups of a community. + pub async fn get_linked_groups_participants( + &self, + community_jid: &Jid, + ) -> Result, anyhow::Error> { + let response = self + .client + .execute(GetLinkedGroupsParticipantsIq::new(community_jid)) + .await?; + Ok(response.into_iter().map(Into::into).collect()) + } +} + +fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option { + let id_str = node.get("id")?.as_str()?; + let jid: Jid = id_str.parse().ok()?; + + // Subject can be a plain string or an object {"value": "..."} + let subject = node + .get("subject") + .and_then(|s| { + s.as_str().map(|v| v.to_string()).or_else(|| { + s.get("value") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + }) + }) + .unwrap_or_default(); + + let participant_count = node + .get("participants_count") + .or_else(|| node.get("total_participants_count")) + .and_then(|c| c.as_u64()) + .map(|c| c as u32); + + // Check if properties indicate general chat + let is_general_from_props = node + .get("properties") + .and_then(|p| p.get("general_chat")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Some(CommunitySubgroup { + id: jid, + subject, + participant_count, + is_default_sub_group: is_default, + is_general_chat: is_general_from_props, + }) +} + +impl Client { + pub fn community(&self) -> Community<'_> { + Community::new(self) + } +} diff --git a/src/features/groups.rs b/src/features/groups.rs index 3d767f46e..77d10f85e 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -48,6 +48,16 @@ pub struct GroupMetadata { pub member_link_mode: Option, /// Total participant count. pub size: Option, + /// Whether this group is a community parent group. + pub is_parent_group: bool, + /// JID of the parent community (for subgroups). + pub parent_group_jid: Option, + /// Whether this is the default announcement subgroup of a community. + pub is_default_sub_group: bool, + /// Whether this is the general chat subgroup of a community. + pub is_general_chat: bool, + /// Whether non-admin community members can create subgroups. + pub allow_non_admin_sub_group_creation: bool, } #[derive(Debug, Clone)] @@ -67,8 +77,8 @@ impl From for GroupParticipant { } } -impl GroupMetadata { - fn from_response(group: GroupInfoResponse) -> Self { +impl From for GroupMetadata { + fn from(group: GroupInfoResponse) -> Self { Self { id: group.id, subject: group.subject.into_string(), @@ -87,6 +97,11 @@ impl GroupMetadata { member_add_mode: group.member_add_mode, member_link_mode: group.member_link_mode, size: group.size, + is_parent_group: group.is_parent_group, + parent_group_jid: group.parent_group_jid, + is_default_sub_group: group.is_default_sub_group, + is_general_chat: group.is_general_chat, + allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation, } } } @@ -150,7 +165,7 @@ impl<'a> Groups<'a> { .into_iter() .map(|group| { let key = group.id.to_string(); - let metadata = GroupMetadata::from_response(group); + let metadata = GroupMetadata::from(group); (key, metadata) }) .collect(); @@ -160,7 +175,7 @@ impl<'a> Groups<'a> { pub async fn get_metadata(&self, jid: &Jid) -> Result { let group = self.client.execute(GroupQueryIq::new(jid)).await?; - Ok(GroupMetadata::from_response(group)) + Ok(GroupMetadata::from(group)) } pub async fn create_group( @@ -390,6 +405,11 @@ mod tests { member_add_mode: None, member_link_mode: None, size: None, + is_parent_group: false, + parent_group_jid: None, + is_default_sub_group: false, + is_general_chat: false, + allow_non_admin_sub_group_creation: false, }; assert_eq!(metadata.subject, "Test Group"); diff --git a/src/features/mod.rs b/src/features/mod.rs index e8c5d1c60..3d67017e1 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,6 +1,7 @@ mod blocking; pub(crate) mod chat_actions; mod chatstate; +mod community; mod contacts; mod groups; mod mex; @@ -14,6 +15,11 @@ pub use blocking::{Blocking, BlocklistEntry}; pub use chat_actions::ChatActions; +pub use community::{ + Community, CommunitySubgroup, CreateCommunityOptions, CreateCommunityResult, GroupType, + LinkSubgroupsResult, UnlinkSubgroupsResult, group_type, +}; + pub use chatstate::{ChatStateType, Chatstate}; pub use contacts::{ContactInfo, Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo}; diff --git a/src/lib.rs b/src/lib.rs index d8b5ee1c2..3a673886e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,14 +46,15 @@ pub mod usync; pub mod features; pub use features::{ - Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, ContactInfo, Contacts, - CreateGroupResult, GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, - GroupParticipantOptions, GroupSubject, Groups, IsOnWhatsAppResult, MemberAddMode, + Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Community, CommunitySubgroup, + ContactInfo, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, + GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, + GroupSubject, GroupType, Groups, IsOnWhatsAppResult, LinkSubgroupsResult, MemberAddMode, MemberLinkMode, MembershipApprovalMode, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse, Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, SetProfilePictureResponse, Status, - StatusPrivacySetting, StatusSendOptions, TcToken, UserInfo, + StatusPrivacySetting, StatusSendOptions, TcToken, UnlinkSubgroupsResult, UserInfo, group_type, }; pub mod bot; diff --git a/tests/e2e/tests/community.rs b/tests/e2e/tests/community.rs new file mode 100644 index 000000000..7c078b65d --- /dev/null +++ b/tests/e2e/tests/community.rs @@ -0,0 +1,535 @@ +use e2e_tests::TestClient; +use log::info; +use whatsapp_rust::GroupType; +use whatsapp_rust::features::{CreateCommunityOptions, GroupCreateOptions, group_type}; + +#[tokio::test] +async fn test_community_create() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_create").await?; + + let result = client + .client + .community() + .create(CreateCommunityOptions::new("Test Community")) + .await?; + + assert!( + result.gid.server == "g.us", + "community JID should be a group: {}", + result.gid + ); + + info!("Created community: {}", result.gid); + + // Query the community and verify it's a parent group + let metadata = client.client.groups().get_metadata(&result.gid).await?; + assert!(metadata.is_parent_group, "should be a parent group"); + assert_eq!(group_type(&metadata), GroupType::Community); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_create_with_general_chat() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_general").await?; + + let result = client + .client + .community() + .create(CreateCommunityOptions { + name: "Community With General".to_string(), + description: None, + closed: false, + allow_non_admin_sub_group_creation: false, + create_general_chat: true, + }) + .await?; + + info!("Created community: {}", result.gid); + + // Fetch subgroups — should have at least a default announcement subgroup + let subgroups = client.client.community().get_subgroups(&result.gid).await?; + + assert!( + !subgroups.is_empty(), + "community should have at least one subgroup" + ); + + info!( + "Subgroups: {:?}", + subgroups + .iter() + .map(|s| format!( + "{} (default={}, general={})", + s.id, s.is_default_sub_group, s.is_general_chat + )) + .collect::>() + ); + + // Verify default subgroup exists + assert!( + subgroups.iter().any(|s| s.is_default_sub_group), + "should have a default announcement subgroup" + ); + + // Verify general chat subgroup was created (create_general_chat: true) + assert!( + subgroups.iter().any(|s| s.is_general_chat), + "should have a general chat subgroup when create_general_chat is true, got: {:?}", + subgroups + .iter() + .map(|s| format!( + "{} (default={}, general={})", + s.id, s.is_default_sub_group, s.is_general_chat + )) + .collect::>() + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_get_subgroups() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_subgroups").await?; + + let result = client + .client + .community() + .create(CreateCommunityOptions::new("Subgroups Test")) + .await?; + + let subgroups = client.client.community().get_subgroups(&result.gid).await?; + + // A newly created community should have an auto-created default subgroup + assert!( + subgroups.iter().any(|s| s.is_default_sub_group), + "should contain the default announcement subgroup" + ); + + info!( + "Community {} has {} subgroup(s)", + result.gid, + subgroups.len() + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_link_subgroup() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_link").await?; + + // Create a community + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Link Test Community")) + .await?; + + info!("Created community: {}", community.gid); + + // Create a regular group to link as a subgroup + let group = client + .client + .groups() + .create_group(GroupCreateOptions::new("Sub Group")) + .await?; + + info!("Created group: {}", group.gid); + + // Link the group to the community + let link_result = client + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + assert!( + link_result.failed_groups.is_empty(), + "no groups should fail linking: {:?}", + link_result.failed_groups + ); + assert!( + link_result.linked_jids.contains(&group.gid), + "linked JIDs should contain the subgroup" + ); + + info!("Linked subgroup {} to community", group.gid); + + // Verify the subgroup appears in the community's subgroup list + let subgroups = client + .client + .community() + .get_subgroups(&community.gid) + .await?; + + assert!( + subgroups.iter().any(|s| s.id == group.gid), + "linked group should appear in subgroup list" + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_unlink_subgroup() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_unlink").await?; + + // Create community + link a subgroup + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Unlink Test")) + .await?; + + let group = client + .client + .groups() + .create_group(GroupCreateOptions::new("Unlink Sub")) + .await?; + + client + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + info!( + "Linked subgroup {} to community {}", + group.gid, community.gid + ); + + // Unlink the subgroup + let unlink_result = client + .client + .community() + .unlink_subgroups(&community.gid, std::slice::from_ref(&group.gid), false) + .await?; + + assert!( + unlink_result.failed_groups.is_empty(), + "no groups should fail unlinking" + ); + assert!( + unlink_result.unlinked_jids.contains(&group.gid), + "unlinked JIDs should contain the subgroup" + ); + + info!("Unlinked subgroup {}", group.gid); + + // Verify it's gone from the subgroup list + let subgroups = client + .client + .community() + .get_subgroups(&community.gid) + .await?; + + assert!( + !subgroups.iter().any(|s| s.id == group.gid), + "unlinked group should not appear in subgroup list" + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_deactivate() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_deactivate").await?; + + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Deactivate Test")) + .await?; + + info!("Created community: {}", community.gid); + + // Deactivate the community + client.client.community().deactivate(&community.gid).await?; + + info!("Deactivated community: {}", community.gid); + + // Querying the deactivated community should fail or return non-parent + let metadata_result = client.client.groups().get_metadata(&community.gid).await; + match metadata_result { + Ok(metadata) => { + // If the server still returns the group, it should no longer be a parent + assert!( + !metadata.is_parent_group, + "deactivated community should not be a parent group" + ); + } + Err(_) => { + // Expected: the community was deleted + info!("Community no longer queryable (deleted)"); + } + } + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_query_linked_group() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_query_linked").await?; + + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Query Linked Test")) + .await?; + + let group = client + .client + .groups() + .create_group(GroupCreateOptions::new("Queryable Sub")) + .await?; + + client + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + // Query the linked group's metadata from the community + let metadata = client + .client + .community() + .query_linked_group(&community.gid, &group.gid) + .await?; + + assert_eq!(metadata.id, group.gid, "metadata JID should match"); + assert_eq!(metadata.subject, "Queryable Sub"); + + info!( + "Queried linked group: {} (subject={})", + metadata.id, metadata.subject + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_join_subgroup() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client_a = TestClient::connect("e2e_community_join_a").await?; + let client_b = TestClient::connect("e2e_community_join_b").await?; + + let jid_b_pn = client_b + .client + .get_pn() + .await + .expect("Client B should have a PN JID") + .to_non_ad(); + let jid_b_lid = client_b + .client + .get_lid() + .await + .expect("Client B should have a LID JID") + .to_non_ad(); + + // Client A creates a community and links a subgroup + let community = client_a + .client + .community() + .create(CreateCommunityOptions::new("Join Test Community")) + .await?; + + let group = client_a + .client + .groups() + .create_group(GroupCreateOptions::new("Joinable Sub")) + .await?; + + client_a + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + // Add Client B to the community parent group first + client_a + .client + .groups() + .add_participants(&community.gid, std::slice::from_ref(&jid_b_lid)) + .await?; + + info!("Added client B to community, now joining subgroup"); + + // Client B joins the subgroup via the community + let metadata = client_b + .client + .community() + .join_subgroup(&community.gid, &group.gid) + .await?; + + assert_eq!(metadata.id, group.gid); + + // Verify client B is in the subgroup's participant list (may be LID or PN) + let b_in_subgroup = metadata.participants.iter().any(|p| { + p.jid == jid_b_pn || p.jid == jid_b_lid || p.phone_number.as_ref() == Some(&jid_b_pn) + }); + assert!( + b_in_subgroup, + "client B ({} / {}) should be in the subgroup after joining, got: {:?}", + jid_b_pn, + jid_b_lid, + metadata + .participants + .iter() + .map(|p| &p.jid) + .collect::>() + ); + + info!("Client B joined subgroup {}", group.gid); + + client_a.disconnect().await; + client_b.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_get_linked_groups_participants() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_participants").await?; + + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Participants Test")) + .await?; + + // Link a subgroup so linked_groups_participants has something to return + let group = client + .client + .groups() + .create_group(GroupCreateOptions::new("Participants Sub")) + .await?; + + client + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + // Get all participants across linked groups + let participants = client + .client + .community() + .get_linked_groups_participants(&community.gid) + .await?; + + let own_pn = client + .client + .get_pn() + .await + .expect("should have PN JID") + .to_non_ad(); + let own_lid = client + .client + .get_lid() + .await + .expect("should have LID JID") + .to_non_ad(); + + info!( + "Got {} participant(s) across linked groups", + participants.len() + ); + + assert!( + !participants.is_empty(), + "should return at least the creator as a participant across linked groups" + ); + + let creator_found = participants + .iter() + .any(|p| p.jid == own_pn || p.jid == own_lid || p.phone_number.as_ref() == Some(&own_pn)); + assert!( + creator_found, + "creator ({} / {}) should be in linked groups participants, got: {:?}", + own_pn, + own_lid, + participants.iter().map(|p| &p.jid).collect::>() + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_community_subgroup_participant_counts() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_community_counts").await?; + + let community = client + .client + .community() + .create(CreateCommunityOptions::new("Counts Test")) + .await?; + + // Link a subgroup + let group = client + .client + .groups() + .create_group(GroupCreateOptions::new("Count Sub")) + .await?; + + client + .client + .community() + .link_subgroups(&community.gid, std::slice::from_ref(&group.gid)) + .await?; + + // Fetch participant counts + let counts = client + .client + .community() + .get_subgroup_participant_counts(&community.gid) + .await?; + + info!("Subgroup participant counts: {:?}", counts); + + // The linked subgroup should appear with a count >= 1 (at least the creator) + let subgroup_count = counts + .iter() + .find(|(jid, _)| *jid == group.gid) + .map(|(_, count)| *count); + + assert!( + subgroup_count.is_some(), + "linked subgroup should appear in participant counts, got: {:?}", + counts + ); + assert!( + subgroup_count.unwrap() >= 1, + "subgroup participant count should be >= 1, got: {}", + subgroup_count.unwrap() + ); + + client.disconnect().await; + Ok(()) +} diff --git a/wacore/src/iq/community.rs b/wacore/src/iq/community.rs new file mode 100644 index 000000000..7ebb5eba3 --- /dev/null +++ b/wacore/src/iq/community.rs @@ -0,0 +1,11 @@ +/// MEX (GraphQL) document IDs for community operations. +pub mod mex_docs { + /// Fetch all subgroups of a community (WAWebMexFetchAllSubgroupsJobQuery). + pub const FETCH_ALL_SUBGROUPS: &str = "9935467776504344"; + + /// Fetch subgroup suggestions (WAWebMexFetchSubgroupSuggestionsJobQuery). + pub const FETCH_SUBGROUP_SUGGESTIONS: &str = "23972005349071865"; + + /// Query subgroup participant counts (WAWebMexQuerySubgroupParticipantCountJobQuery). + pub const FETCH_SUBGROUP_PARTICIPANT_COUNT: &str = "24079399904996141"; +} diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index c599a6028..e9dc0d019 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -157,6 +157,21 @@ pub struct GroupCreateOptions { pub membership_approval_mode: Option, #[builder(default = Some(0), setter(strip_option))] pub ephemeral_expiration: Option, + /// Create as a community (parent group). Emits `` in the create stanza. + #[builder(default)] + pub is_parent: bool, + /// Whether the community is closed (requires approval to join). + /// Only used when `is_parent` is true. + #[builder(default)] + pub closed: bool, + /// Allow non-admin members to create subgroups. + /// Only used when `is_parent` is true. + #[builder(default)] + pub allow_non_admin_sub_group_creation: bool, + /// Create a general chat subgroup alongside the community. + /// Only used when `is_parent` is true. + #[builder(default)] + pub create_general_chat: bool, } impl GroupCreateOptions { @@ -208,6 +223,10 @@ impl Default for GroupCreateOptions { member_add_mode: Some(MemberAddMode::AllMemberAdd), membership_approval_mode: Some(MembershipApprovalMode::Off), ephemeral_expiration: Some(0), + is_parent: false, + closed: false, + allow_non_admin_sub_group_creation: false, + create_general_chat: false, } } } @@ -292,6 +311,23 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { ); } + // Community (parent group) fields + if options.is_parent { + let mut parent_builder = NodeBuilder::new("parent"); + if options.closed { + parent_builder = + parent_builder.attr("default_membership_approval_mode", "request_required"); + } + children.push(parent_builder.build()); + + if options.allow_non_admin_sub_group_creation { + children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build()); + } + if options.create_general_chat { + children.push(NodeBuilder::new("create_general_chat").build()); + } + } + NodeBuilder::new("create") .attr("subject", &options.subject) .children(children) @@ -388,6 +424,16 @@ pub struct GroupInfoResponse { pub member_link_mode: Option, /// Total participant count (from `size` attribute, useful for large groups). pub size: Option, + /// Whether this group is a community parent group (has `` child). + pub is_parent_group: bool, + /// JID of the parent community (for subgroups, from ``). + pub parent_group_jid: Option, + /// Whether this is the default announcement subgroup of a community. + pub is_default_sub_group: bool, + /// Whether this is the general chat subgroup of a community. + pub is_general_chat: bool, + /// Whether non-admin community members can create subgroups. + pub allow_non_admin_sub_group_creation: bool, } impl ProtocolNode for GroupInfoResponse { @@ -446,6 +492,27 @@ impl ProtocolNode for GroupInfoResponse { children.push(desc_builder.string_content(desc.as_str()).build()); } + // Community fields + if self.is_parent_group { + children.push(NodeBuilder::new("parent").build()); + } + if let Some(ref parent_jid) = self.parent_group_jid { + children.push( + NodeBuilder::new("linked_parent") + .attr("jid", parent_jid.clone()) + .build(), + ); + } + if self.is_default_sub_group { + children.push(NodeBuilder::new("default_sub_group").build()); + } + if self.is_general_chat { + children.push(NodeBuilder::new("general_chat").build()); + } + if self.allow_non_admin_sub_group_creation { + children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build()); + } + let mut builder = NodeBuilder::new("group") .attr("id", self.id) .attr("subject", self.subject.as_str()) @@ -557,6 +624,19 @@ impl ProtocolNode for GroupInfoResponse { .and_then(|n| n.attrs().optional_string("id")) .map(|s| s.to_string()); + // Parse community fields + let is_parent_group = node.get_optional_child_by_tag(&["parent"]).is_some(); + let parent_group_jid = node + .get_optional_child_by_tag(&["linked_parent"]) + .and_then(|n| n.attrs().optional_jid("jid")); + let is_default_sub_group = node + .get_optional_child_by_tag(&["default_sub_group"]) + .is_some(); + let is_general_chat = node.get_optional_child_by_tag(&["general_chat"]).is_some(); + let allow_non_admin_sub_group_creation = node + .get_optional_child_by_tag(&["allow_non_admin_sub_group_creation"]) + .is_some(); + Ok(Self { id, subject, @@ -575,6 +655,11 @@ impl ProtocolNode for GroupInfoResponse { member_add_mode, member_link_mode, size, + is_parent_group, + parent_group_jid, + is_default_sub_group, + is_general_chat, + allow_non_admin_sub_group_creation, }) } } @@ -1340,6 +1425,357 @@ impl IqSpec for SetGroupMembershipApprovalIq { } } +// --------------------------------------------------------------------------- +// Community IQ Specs +// --------------------------------------------------------------------------- + +/// Response for a single group in a link/unlink operation. +#[derive(Debug, Clone)] +pub struct LinkedGroupResult { + pub jid: Jid, + /// Error code if the operation failed for this group (e.g. 406 = community full). + pub error: Option, +} + +/// Response from linking subgroups to a community. +#[derive(Debug, Clone)] +pub struct LinkSubgroupsResponse { + pub groups: Vec, +} + +/// Response from unlinking subgroups from a community. +#[derive(Debug, Clone)] +pub struct UnlinkSubgroupsResponse { + pub groups: Vec, +} + +/// IQ specification for linking subgroups to a community parent group. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct LinkSubgroupsIq { + pub parent_jid: Jid, + pub subgroup_jids: Vec, +} + +impl LinkSubgroupsIq { + pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid]) -> Self { + Self { + parent_jid: parent_jid.clone(), + subgroup_jids: subgroup_jids.to_vec(), + } + } +} + +impl IqSpec for LinkSubgroupsIq { + type Response = LinkSubgroupsResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let group_nodes: Vec = self + .subgroup_jids + .iter() + .map(|jid| NodeBuilder::new("group").attr("jid", jid.clone()).build()) + .collect(); + + let link_node = NodeBuilder::new("link") + .attr("link_type", "sub_group") + .children(group_nodes) + .build(); + + let links_node = NodeBuilder::new("links").children([link_node]).build(); + + InfoQuery::set_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![links_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let links_node = required_child(response, "links")?; + let link_node = required_child(links_node, "link")?; + + let mut groups = Vec::new(); + for child in link_node.get_children_by_tag("group") { + let jid_str = required_attr(child, "jid")?; + let jid: Jid = jid_str.parse()?; + let error = child + .attrs() + .optional_string("error") + .and_then(|s| s.parse::().ok()); + groups.push(LinkedGroupResult { jid, error }); + } + + Ok(LinkSubgroupsResponse { groups }) + } +} + +/// IQ specification for unlinking subgroups from a community parent group. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct UnlinkSubgroupsIq { + pub parent_jid: Jid, + pub subgroup_jids: Vec, + pub remove_orphan_members: bool, +} + +impl UnlinkSubgroupsIq { + pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid], remove_orphan_members: bool) -> Self { + Self { + parent_jid: parent_jid.clone(), + subgroup_jids: subgroup_jids.to_vec(), + remove_orphan_members, + } + } +} + +impl IqSpec for UnlinkSubgroupsIq { + type Response = UnlinkSubgroupsResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let group_nodes: Vec = self + .subgroup_jids + .iter() + .map(|jid| { + let mut builder = NodeBuilder::new("group").attr("jid", jid.clone()); + if self.remove_orphan_members { + builder = builder.attr("remove_orphaned_members", "true"); + } + builder.build() + }) + .collect(); + + let unlink_node = NodeBuilder::new("unlink") + .attr("unlink_type", "sub_group") + .children(group_nodes) + .build(); + + InfoQuery::set_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![unlink_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let unlink_node = required_child(response, "unlink")?; + + let mut groups = Vec::new(); + for child in unlink_node.get_children_by_tag("group") { + let jid_str = required_attr(child, "jid")?; + let jid: Jid = jid_str.parse()?; + let error = child + .attrs() + .optional_string("error") + .and_then(|s| s.parse::().ok()); + groups.push(LinkedGroupResult { jid, error }); + } + + Ok(UnlinkSubgroupsResponse { groups }) + } +} + +/// IQ specification for deleting (deactivating) a community. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct DeleteCommunityIq { + pub parent_jid: Jid, +} + +impl DeleteCommunityIq { + pub fn new(parent_jid: &Jid) -> Self { + Self { + parent_jid: parent_jid.clone(), + } + } +} + +impl IqSpec for DeleteCommunityIq { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::set_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("delete_parent").build(), + ])), + ) + } + + fn parse_response(&self, _response: &Node) -> Result { + Ok(()) + } +} + +/// IQ specification for querying a linked subgroup's info from the parent community. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct QueryLinkedGroupIq { + pub parent_jid: Jid, + pub subgroup_jid: Jid, +} + +impl QueryLinkedGroupIq { + pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self { + Self { + parent_jid: parent_jid.clone(), + subgroup_jid: subgroup_jid.clone(), + } + } +} + +impl IqSpec for QueryLinkedGroupIq { + type Response = GroupInfoResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let query_node = NodeBuilder::new("query_linked") + .attr("type", "sub_group") + .attr("jid", self.subgroup_jid.clone()) + .build(); + + InfoQuery::get_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![query_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let linked_node = required_child(response, "linked_group")?; + let group_node = required_child(linked_node, "group")?; + GroupInfoResponse::try_from_node(group_node) + } +} + +/// IQ specification for joining a linked subgroup via the parent community. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct JoinLinkedGroupIq { + pub parent_jid: Jid, + pub subgroup_jid: Jid, +} + +impl JoinLinkedGroupIq { + pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self { + Self { + parent_jid: parent_jid.clone(), + subgroup_jid: subgroup_jid.clone(), + } + } +} + +impl IqSpec for JoinLinkedGroupIq { + type Response = GroupInfoResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let node = NodeBuilder::new("join_linked_group") + .attr("jid", self.subgroup_jid.clone()) + .build(); + + InfoQuery::set_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let linked_node = required_child(response, "linked_group")?; + let group_node = required_child(linked_node, "group")?; + GroupInfoResponse::try_from_node(group_node) + } +} + +/// IQ specification for getting all participants across linked groups. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct GetLinkedGroupsParticipantsIq { + pub parent_jid: Jid, +} + +impl GetLinkedGroupsParticipantsIq { + pub fn new(parent_jid: &Jid) -> Self { + Self { + parent_jid: parent_jid.clone(), + } + } +} + +impl IqSpec for GetLinkedGroupsParticipantsIq { + type Response = Vec; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get_ref( + GROUP_IQ_NAMESPACE, + &self.parent_jid, + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("linked_groups_participants").build(), + ])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let container = required_child(response, "linked_groups_participants")?; + + // Participants may be direct children or nested inside nodes. + let direct = collect_children::(container, "participant")?; + if !direct.is_empty() { + return Ok(direct); + } + + // Nested: + let mut all = Vec::new(); + for group_node in container.get_children_by_tag("group") { + let participants = + collect_children::(group_node, "participant")?; + all.extend(participants); + } + Ok(all) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1677,4 +2113,263 @@ mod tests { panic!("expected nodes content"); } } + + // ----------------------------------------------------------------------- + // Community IQ spec tests + // ----------------------------------------------------------------------- + + #[test] + fn test_build_create_community_node() { + let options = GroupCreateOptions { + subject: "My Community".to_string(), + is_parent: true, + closed: true, + allow_non_admin_sub_group_creation: true, + create_general_chat: true, + ..Default::default() + }; + + let node = build_create_group_node(&options); + assert_eq!(node.tag, "create"); + + // Should have + let parent = node.get_children_by_tag("parent").next().unwrap(); + assert_eq!( + parent + .attrs() + .optional_string("default_membership_approval_mode") + .as_deref(), + Some("request_required") + ); + + assert!( + node.get_children_by_tag("allow_non_admin_sub_group_creation") + .next() + .is_some() + ); + assert!( + node.get_children_by_tag("create_general_chat") + .next() + .is_some() + ); + } + + #[test] + fn test_build_create_non_community_omits_parent() { + let options = GroupCreateOptions { + subject: "Regular Group".to_string(), + is_parent: false, + ..Default::default() + }; + + let node = build_create_group_node(&options); + assert!( + node.get_children_by_tag("parent").next().is_none(), + "non-community group should not have " + ); + } + + #[test] + fn test_link_subgroups_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub)); + let iq = spec.build_iq(); + + assert_eq!(iq.to, parent); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let links = &nodes[0]; + assert_eq!(links.tag, "links"); + let link = links.get_children_by_tag("link").next().unwrap(); + assert_eq!( + link.attrs().optional_string("link_type").as_deref(), + Some("sub_group") + ); + let group = link.get_children_by_tag("group").next().unwrap(); + assert_eq!(group.attrs().optional_jid("jid"), Some(sub)); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_link_subgroups_iq_parse_response() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("links") + .children([NodeBuilder::new("link") + .attr("link_type", "sub_group") + .children([NodeBuilder::new("group") + .attr("jid", sub.to_string()) + .build()]) + .build()]) + .build()]) + .build(); + + let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub)); + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.groups.len(), 1); + assert_eq!(result.groups[0].jid, sub); + assert!(result.groups[0].error.is_none()); + } + + #[test] + fn test_unlink_subgroups_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), true); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let unlink = &nodes[0]; + assert_eq!(unlink.tag, "unlink"); + assert_eq!( + unlink.attrs().optional_string("unlink_type").as_deref(), + Some("sub_group") + ); + let group = unlink.get_children_by_tag("group").next().unwrap(); + assert_eq!(group.attrs().optional_jid("jid"), Some(sub)); + assert_eq!( + group + .attrs() + .optional_string("remove_orphaned_members") + .as_deref(), + Some("true") + ); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_unlink_subgroups_iq_parse_response_with_error() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("unlink") + .attr("unlink_type", "sub_group") + .children([NodeBuilder::new("group") + .attr("jid", sub.to_string()) + .attr("error", "406") + .build()]) + .build()]) + .build(); + + let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), false); + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.groups.len(), 1); + assert_eq!(result.groups[0].jid, sub); + assert_eq!(result.groups[0].error, Some(406)); + } + + #[test] + fn test_delete_community_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let spec = DeleteCommunityIq::new(&parent); + let iq = spec.build_iq(); + + assert_eq!(iq.to, parent); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].tag, "delete_parent"); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_query_linked_group_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let spec = QueryLinkedGroupIq::new(&parent, &sub); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let query = &nodes[0]; + assert_eq!(query.tag, "query_linked"); + assert_eq!( + query.attrs().optional_string("type").as_deref(), + Some("sub_group") + ); + assert_eq!(query.attrs().optional_jid("jid"), Some(sub)); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_join_linked_group_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let sub: Jid = "120363000000000002@g.us".parse().unwrap(); + + let spec = JoinLinkedGroupIq::new(&parent, &sub); + let iq = spec.build_iq(); + + assert_eq!(iq.to, parent); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let join = &nodes[0]; + assert_eq!(join.tag, "join_linked_group"); + assert_eq!(join.attrs().optional_jid("jid"), Some(sub)); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_get_linked_groups_participants_iq_build() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let spec = GetLinkedGroupsParticipantsIq::new(&parent); + let iq = spec.build_iq(); + + assert_eq!(iq.to, parent); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].tag, "linked_groups_participants"); + } else { + panic!("expected nodes content"); + } + } + + #[test] + fn test_group_info_response_parses_community_fields() { + let node = NodeBuilder::new("group") + .attr("id", "120363000000000001@g.us") + .attr("subject", "My Community") + .children([ + NodeBuilder::new("parent").build(), + NodeBuilder::new("allow_non_admin_sub_group_creation").build(), + ]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + assert!(response.is_parent_group); + assert!(response.allow_non_admin_sub_group_creation); + assert!(response.parent_group_jid.is_none()); + assert!(!response.is_default_sub_group); + assert!(!response.is_general_chat); + } + + #[test] + fn test_group_info_response_parses_subgroup_fields() { + let parent_jid = "120363000000000001@g.us"; + let node = NodeBuilder::new("group") + .attr("id", "120363000000000002@g.us") + .attr("subject", "Sub Group") + .children([ + NodeBuilder::new("linked_parent") + .attr("jid", parent_jid) + .build(), + NodeBuilder::new("default_sub_group").build(), + ]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + assert!(!response.is_parent_group); + assert!(response.is_default_sub_group); + assert_eq!(response.parent_group_jid, Some(parent_jid.parse().unwrap())); + } } diff --git a/wacore/src/iq/mod.rs b/wacore/src/iq/mod.rs index 9e982f25a..a110f892d 100644 --- a/wacore/src/iq/mod.rs +++ b/wacore/src/iq/mod.rs @@ -1,5 +1,6 @@ pub mod blocklist; pub mod chatstate; +pub mod community; pub mod contacts; pub mod dirty; pub mod groups;