1use crate::client::Client;
2use crate::features::mex::{MexError, mex_request};
3use crate::request::IqError;
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::sync::Arc;
7use thiserror::Error;
8use wacore::client::context::GroupInfo;
9use wacore::iq::contacts::SetProfilePictureSpec;
10pub use wacore::iq::contacts::SetProfilePictureResponse;
13use wacore::iq::groups::{
14 AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AcknowledgeGroupIq, AddParticipantsIq,
15 BatchGetGroupInfoIq, CancelMembershipRequestsIq, DemoteParticipantsIq, GetGroupInviteInfoIq,
16 GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq,
17 GroupInfoOutcome, GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq,
18 GroupQueryIq, LeaveGroupIq, MembershipRequestActionIq, PromoteParticipantsIq,
19 RemoveParticipantsIncludingLinkedGroupsIq, RemoveParticipantsIq, RevokeRequestCodeIq,
20 SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq,
21 SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq,
22 SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants,
23};
24use wacore::iq::mex_operations::update_group_property;
25use wacore::types::message::AddressingMode;
26use wacore_binary::{Jid, JidExt as _};
27
28use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult;
29pub use wacore::iq::groups::{
30 GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings,
31 GroupJoinError, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture,
32 GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode,
33 MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse,
34 ParticipantType, PictureType,
35};
36
37#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum GroupError {
42 #[error("{0}")]
44 Iq(#[from] IqError),
45 #[error("{0}")]
47 Mex(#[from] MexError),
48 #[error("invalid group request: {0}")]
51 InvalidRequest(String),
52 #[error("the group description changed since it was read")]
57 DescriptionConflict,
58 #[error("{0}")]
61 Internal(#[from] anyhow::Error),
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum PreviousDescription<'a> {
76 #[default]
79 Resolve,
80 Absent,
83 Id(&'a str),
86}
87
88impl<'a> From<Option<&'a str>> for PreviousDescription<'a> {
96 fn from(description_id: Option<&'a str>) -> Self {
97 match description_id {
98 Some(id) => Self::Id(id),
99 None => Self::Absent,
100 }
101 }
102}
103
104const CONFLICT_STATUS_CODE: u16 = 409;
107
108#[derive(serde::Serialize)]
114#[serde(rename_all = "snake_case")]
115enum GroupPropertyUpdate {
116 MemberLinkMode(&'static str),
117 MemberShareGroupHistoryMode(&'static str),
118 LimitSharing(LimitSharingUpdate),
119}
120
121#[derive(serde::Serialize)]
122struct LimitSharingUpdate {
123 limit_sharing_enabled: bool,
124 limit_sharing_trigger: &'static str,
125}
126
127#[derive(serde::Serialize)]
128struct UpdateGroupPropertyVars {
129 group_id: String,
130 update: GroupPropertyUpdate,
131}
132
133#[derive(Debug, Clone)]
135pub enum BatchGroupResult {
136 Full(Box<GroupMetadata>),
137 Truncated {
139 id: Jid,
140 size: Option<u32>,
141 },
142 Forbidden(Jid),
143 NotFound(Jid),
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct GroupMetadata {
148 pub id: Jid,
149 pub subject: String,
150 pub notify: Option<String>,
151 pub participants: Vec<GroupParticipant>,
152 pub addressing_mode: AddressingMode,
153 pub creator: Option<Jid>,
155 pub creator_pn: Option<Jid>,
156 pub creator_username: Option<String>,
157 pub creator_country_code: Option<String>,
158 pub creation_time: Option<u64>,
160 pub participant_version_id: Option<String>,
161 pub admin_version_id: Option<String>,
162 pub open_thread_id: Option<String>,
163 pub has_missing_participant_identification: bool,
164 pub subject_time: Option<u64>,
166 pub subject_owner: Option<Jid>,
168 pub subject_owner_pn: Option<Jid>,
169 pub subject_owner_username: Option<String>,
170 pub description: Option<String>,
172 pub description_id: Option<String>,
174 pub description_owner: Option<Jid>,
176 pub description_owner_pn: Option<Jid>,
177 pub description_owner_username: Option<String>,
178 pub description_time: Option<u64>,
180 pub is_locked: bool,
182 pub is_announcement: bool,
184 pub ephemeral: Option<GroupEphemeralSettings>,
186 pub membership_approval: bool,
188 pub member_add_mode: Option<MemberAddMode>,
190 pub member_link_mode: Option<MemberLinkMode>,
192 pub size: Option<u32>,
194 pub is_parent_group: bool,
196 pub parent_membership_approval_required: bool,
197 pub parent_group_jid: Option<Jid>,
199 pub is_default_sub_group: bool,
201 pub is_general_chat: bool,
203 pub allow_non_admin_sub_group_creation: bool,
205 pub no_frequently_forwarded: bool,
207 pub member_share_history_mode: Option<MemberShareHistoryMode>,
209 pub growth_locked: Option<GrowthLockInfo>,
211 pub is_suspended: bool,
213 pub suspension_can_auto_file: bool,
214 pub appeal_status: Option<GroupAppealStatus>,
215 pub appeal_update_time: Option<u64>,
216 pub is_support_group: bool,
217 pub allow_admin_reports: bool,
219 pub is_hidden_group: bool,
221 pub is_incognito: bool,
223 pub has_group_history: bool,
225 pub is_auto_add_disabled: bool,
226 pub has_capi: bool,
227 pub evolution_version: Option<u32>,
228 pub has_group_safety_check: bool,
229 pub participant_label_enabled: bool,
230 pub is_limit_sharing_enabled: bool,
232 pub limit_sharing_trigger: Option<u32>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct GroupParticipant {
237 pub jid: Jid,
238 pub phone_number: Option<Jid>,
239 pub lid: Option<Jid>,
240 pub username: Option<wacore_binary::CompactString>,
241 pub participant_type: ParticipantType,
242 pub details: Option<Box<GroupParticipantDetails>>,
243}
244
245impl GroupParticipant {
246 pub fn is_admin(&self) -> bool {
247 self.participant_type.is_admin()
248 }
249
250 pub fn is_super_admin(&self) -> bool {
251 self.participant_type == ParticipantType::SuperAdmin
252 }
253}
254
255impl From<GroupParticipantResponse> for GroupParticipant {
256 fn from(p: GroupParticipantResponse) -> Self {
257 Self {
258 jid: p.jid,
259 phone_number: p.phone_number,
260 lid: p.lid,
261 username: p.username,
262 participant_type: p.participant_type,
263 details: p.details,
264 }
265 }
266}
267
268impl From<GroupInfoResponse> for GroupMetadata {
269 fn from(group: GroupInfoResponse) -> Self {
270 Self {
271 id: group.id,
272 subject: group.subject.into_string(),
273 notify: group.notify,
274 participants: group.participants.into_iter().map(Into::into).collect(),
275 addressing_mode: group.addressing_mode,
276 creator: group.creator,
277 creator_pn: group.creator_pn,
278 creator_username: group.creator_username,
279 creator_country_code: group.creator_country_code,
280 creation_time: group.creation_time,
281 participant_version_id: group.participant_version_id,
282 admin_version_id: group.admin_version_id,
283 open_thread_id: group.open_thread_id,
284 has_missing_participant_identification: group.has_missing_participant_identification,
285 subject_time: group.subject_time,
286 subject_owner: group.subject_owner,
287 subject_owner_pn: group.subject_owner_pn,
288 subject_owner_username: group.subject_owner_username,
289 description: group.description,
290 description_id: group.description_id,
291 description_owner: group.description_owner,
292 description_owner_pn: group.description_owner_pn,
293 description_owner_username: group.description_owner_username,
294 description_time: group.description_time,
295 is_locked: group.is_locked,
296 is_announcement: group.is_announcement,
297 ephemeral: group.ephemeral,
298 membership_approval: group.membership_approval,
299 member_add_mode: group.member_add_mode,
300 member_link_mode: group.member_link_mode,
301 size: group.size,
302 is_parent_group: group.is_parent_group,
303 parent_membership_approval_required: group.parent_membership_approval_required,
304 parent_group_jid: group.parent_group_jid,
305 is_default_sub_group: group.is_default_sub_group,
306 is_general_chat: group.is_general_chat,
307 allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation,
308 no_frequently_forwarded: group.no_frequently_forwarded,
309 member_share_history_mode: group.member_share_history_mode,
310 growth_locked: group.growth_locked,
311 is_suspended: group.is_suspended,
312 suspension_can_auto_file: group.suspension_can_auto_file,
313 appeal_status: group.appeal_status,
314 appeal_update_time: group.appeal_update_time,
315 is_support_group: group.is_support_group,
316 allow_admin_reports: group.allow_admin_reports,
317 is_hidden_group: group.is_hidden_group,
318 is_incognito: group.is_incognito,
319 has_group_history: group.has_group_history,
320 is_auto_add_disabled: group.is_auto_add_disabled,
321 has_capi: group.has_capi,
322 evolution_version: group.evolution_version,
323 has_group_safety_check: group.has_group_safety_check,
324 participant_label_enabled: group.participant_label_enabled,
325 is_limit_sharing_enabled: group.is_limit_sharing_enabled,
326 limit_sharing_trigger: group.limit_sharing_trigger,
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
332#[non_exhaustive]
333pub struct CreateGroupResult {
334 pub metadata: GroupMetadata,
335}
336
337pub struct Groups<'a> {
338 client: &'a Client,
339}
340
341pub(crate) struct GroupMetadataGuard<'a> {
346 client: &'a Client,
347 jid: &'a Jid,
348 _guard: async_lock::MutexGuardArc<()>,
349}
350
351impl GroupMetadataGuard<'_> {
352 pub(crate) async fn current(&self) -> Option<Arc<GroupInfo>> {
353 self.client.get_group_cache().await.get(self.jid).await
354 }
355
356 async fn cache(&self, info: Arc<GroupInfo>) {
357 self.client
358 .get_group_cache()
359 .await
360 .insert(self.jid.clone(), info)
361 .await;
362 }
363
364 pub(crate) async fn publish(&self, info: Arc<GroupInfo>) {
365 let jid = self.jid.to_string();
366 match serde_json::to_vec(info.as_ref()) {
367 Ok(blob) => {
368 if let Err(error) = self
369 .client
370 .persistence_manager
371 .backend()
372 .put_group_metadata(&jid, &blob)
373 .await
374 {
375 log::warn!("Failed to persist group metadata for {}: {error}", self.jid);
376 }
377 }
378 Err(error) => {
379 log::warn!(
380 "Failed to serialize group metadata for {}: {error}",
381 self.jid
382 );
383 }
384 }
385
386 self.cache(info).await;
387 }
388
389 pub(crate) async fn invalidate(&self) {
390 if let Err(error) = self
391 .client
392 .persistence_manager
393 .backend()
394 .delete_group_metadata(&self.jid.to_string())
395 .await
396 {
397 log::warn!(
398 "Failed to invalidate persisted group metadata for {}: {error}",
399 self.jid
400 );
401 }
402 self.client
403 .get_group_cache()
404 .await
405 .invalidate(self.jid)
406 .await;
407 }
408}
409
410#[derive(Clone, Copy)]
411enum ParticipantRemovalScope {
412 Group,
413 LinkedGroups,
414}
415
416impl<'a> Groups<'a> {
417 pub(crate) fn new(client: &'a Client) -> Self {
418 Self { client }
419 }
420
421 pub async fn query_info(&self, jid: &Jid) -> Result<Arc<GroupInfo>, GroupError> {
435 self.query_info_with_freshness(jid, crate::cache::Freshness::CachePreferred)
436 .await
437 }
438
439 pub async fn query_info_with_freshness(
444 &self,
445 jid: &Jid,
446 freshness: crate::cache::Freshness,
447 ) -> Result<Arc<GroupInfo>, GroupError> {
448 let cache = self.client.get_group_cache().await;
449 let mut cached = cache.get(jid).await;
450 if freshness == crate::cache::Freshness::CachePreferred
451 && let Some(cached) = cached.take()
452 {
453 return Ok(cached);
454 }
455
456 self.query_info_from_source(jid, cached).await
457 }
458
459 #[expect(
460 clippy::manual_async_fn,
461 reason = "the explicit async block keeps the network-bound state machine out of line"
462 )]
463 fn query_info_from_source<'b>(
464 &'b self,
465 jid: &'b Jid,
466 mut cached: Option<Arc<GroupInfo>>,
467 ) -> impl Future<Output = Result<Arc<GroupInfo>, GroupError>> + 'b {
468 #[inline(never)]
473 async move {
474 let jid_str = jid.to_string();
475 let backend = self.client.persistence_manager.backend();
476 loop {
477 let (persisted, mut cold_metadata) = if cached.is_some() {
483 (None, None)
484 } else {
485 let metadata = self.client.lock_group_metadata(jid).await;
486 if let Some(current) = metadata.current().await {
487 cached = Some(current);
488 drop(metadata);
489 continue;
490 }
491 let persisted = match backend.get_group_metadata(&jid_str).await {
492 Ok(Some(blob)) => serde_json::from_slice(&blob).ok(),
493 _ => None,
494 };
495 (persisted, Some(metadata))
496 };
497 let phash = cached.as_deref().or(persisted.as_ref()).and_then(|info| {
498 wacore::messages::MessageUtils::participant_list_hash(&info.participants).ok()
499 });
500
501 let group = match self
502 .client
503 .execute(GroupQueryIq::with_phash(jid, phash))
504 .await?
505 {
506 GroupInfoOutcome::NotModified => {
507 if let Some(metadata) = cold_metadata.take() {
508 let info = Arc::new(persisted.ok_or_else(|| {
509 GroupError::InvalidRequest(
510 "server returned not-modified group but nothing was cached"
511 .into(),
512 )
513 })?);
514 metadata.cache(Arc::clone(&info)).await;
515 return Ok(info);
516 }
517
518 let metadata = self.client.lock_group_metadata(jid).await;
522 if let Some(current) = metadata.current().await {
523 return Ok(current);
524 }
525
526 drop(metadata);
530 cached = None;
531 continue;
532 }
533 GroupInfoOutcome::Full(group) => *group,
534 };
535
536 let participant_count = group.participants.len();
538 let is_lid = group.addressing_mode == AddressingMode::Lid;
539 let mut participants = Vec::with_capacity(participant_count);
540 let mut lid_to_pn_map: HashMap<wacore_binary::CompactString, Jid> = if is_lid {
541 HashMap::with_capacity(participant_count)
542 } else {
543 HashMap::new()
544 };
545 for participant in group.participants {
546 if is_lid && let Some(pn) = participant.phone_number {
547 lid_to_pn_map.insert(participant.jid.user.clone(), pn);
548 }
549 participants.push(participant.jid);
550 }
551
552 if !lid_to_pn_map.is_empty()
556 && let Some(client_arc) = self.client.self_weak.get().and_then(|w| w.upgrade())
557 {
558 let mut batch = Vec::with_capacity(lid_to_pn_map.len());
559 for (lid_user, pn_jid) in &lid_to_pn_map {
560 if pn_jid.is_pn() {
561 batch.push((lid_user.as_str().to_string(), pn_jid.user.to_string()));
562 }
563 }
564 client_arc
565 .learn_lid_pn_mappings_batch(
566 batch,
567 crate::lid_pn_cache::LearningSource::Other,
568 false,
569 )
570 .await;
571 }
572
573 let mut info = GroupInfo::new(participants, group.addressing_mode);
574 info.is_community_announce = Some(group.is_default_sub_group);
575 if !lid_to_pn_map.is_empty() {
576 info.set_lid_to_pn_map(lid_to_pn_map);
577 }
578 let info = Arc::new(info);
579
580 let metadata = match cold_metadata {
584 Some(metadata) => metadata,
585 None => {
586 let metadata = self.client.lock_group_metadata(jid).await;
587 let current = metadata.current().await;
588 let unchanged = matches!(
589 (cached.as_ref(), current.as_ref()),
590 (Some(expected), Some(current)) if Arc::ptr_eq(expected, current)
591 );
592 if !unchanged {
593 drop(metadata);
594 if let Some(current) = current {
595 return Ok(current);
596 }
597 cached = None;
598 continue;
599 }
600 metadata
601 }
602 };
603
604 metadata.publish(Arc::clone(&info)).await;
605 return Ok(info);
606 }
607 }
608 }
609
610 pub(super) async fn fill_participant_pns(&self, meta: &mut GroupMetadata) {
617 if meta.addressing_mode != AddressingMode::Lid {
618 return;
619 }
620 let pending: Vec<(usize, Jid)> = meta
622 .participants
623 .iter()
624 .enumerate()
625 .filter(|(_, p)| p.phone_number.is_none() && p.jid.is_lid())
626 .map(|(i, p)| (i, p.jid.clone()))
627 .collect();
628 if pending.is_empty() {
629 return;
630 }
631
632 use futures::StreamExt;
635 const LID_PN_RESOLVE_CONCURRENCY: usize = 16;
636 let resolved: Vec<(usize, Jid)> = futures::stream::iter(pending)
637 .map(|(i, jid)| async move {
638 let pn = self
639 .client
640 .get_lid_pn_entry(&jid)
641 .await
642 .ok()
643 .flatten()
644 .map(|e| Jid::pn(&*e.phone_number));
645 (i, pn)
646 })
647 .buffer_unordered(LID_PN_RESOLVE_CONCURRENCY)
648 .filter_map(|(i, pn)| async move { pn.map(|pn| (i, pn)) })
649 .collect()
650 .await;
651
652 for (i, pn) in resolved {
653 meta.participants[i].phone_number = Some(pn);
654 }
655 }
656
657 pub async fn get_participating(&self) -> Result<HashMap<Jid, GroupMetadata>, GroupError> {
658 let response = self.client.execute(GroupParticipatingIq::new()).await?;
659
660 let mut result: HashMap<Jid, GroupMetadata> = response
661 .groups
662 .into_iter()
663 .map(|group| {
664 let key = group.id.clone();
665 (key, GroupMetadata::from(group))
666 })
667 .collect();
668
669 for meta in result.values_mut() {
670 self.fill_participant_pns(meta).await;
671 }
672
673 Ok(result)
674 }
675
676 pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, GroupError> {
690 match self.client.execute(GroupQueryIq::new(jid)).await? {
692 GroupInfoOutcome::Full(group) => {
693 let mut meta = GroupMetadata::from(*group);
694 self.fill_participant_pns(&mut meta).await;
695 Ok(meta)
696 }
697 GroupInfoOutcome::NotModified => Err(GroupError::InvalidRequest(
698 "group query returned not-modified without a phash".into(),
699 )),
700 }
701 }
702
703 pub async fn create_group(
704 &self,
705 mut options: GroupCreateOptions,
706 ) -> Result<CreateGroupResult, GroupError> {
707 let mut resolved_participants = Vec::with_capacity(options.participants.len());
709
710 for participant in options.participants {
711 let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
712 let entry = self
713 .client
714 .get_lid_pn_entry(&participant.jid)
715 .await?
716 .ok_or_else(|| {
717 GroupError::InvalidRequest(format!(
718 "missing phone number mapping for LID {}",
719 participant.jid
720 ))
721 })?;
722 participant.with_phone_number(Jid::pn(&*entry.phone_number))
723 } else {
724 participant
725 };
726 resolved_participants.push(resolved);
727 }
728
729 options.participants = normalize_participants(&resolved_participants);
730
731 if self
732 .client
733 .ab_props()
734 .is_enabled(wacore::iq::abprops::web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE)
735 .await
736 {
737 self.attach_tokens_to_participants(&mut options.participants)
738 .await;
739 }
740
741 let group = self.client.execute(GroupCreateIq::new(options)).await?;
742
743 Ok(CreateGroupResult {
744 metadata: GroupMetadata::from(group),
745 })
746 }
747
748 pub async fn set_subject(
749 &self,
750 jid: impl Into<Jid>,
751 subject: GroupSubject,
752 ) -> Result<(), GroupError> {
753 let jid = &jid.into();
754 Ok(self
755 .client
756 .execute(SetGroupSubjectIq::new(jid, subject))
757 .await?)
758 }
759
760 pub async fn set_description(
773 &self,
774 jid: impl Into<Jid>,
775 description: Option<GroupDescription>,
776 prev: PreviousDescription<'_>,
777 ) -> Result<(), GroupError> {
778 let jid = &jid.into();
779 let prev: Option<Cow<'_, str>> = match prev {
780 PreviousDescription::Absent => None,
781 PreviousDescription::Id(id) => Some(Cow::Borrowed(id)),
782 PreviousDescription::Resolve => self.query_description_id(jid).await?.map(Cow::Owned),
786 };
787
788 self.client
789 .execute(SetGroupDescriptionIq::new(
790 jid,
791 description,
792 prev.as_deref(),
793 ))
794 .await
795 .map_err(|err| match err {
796 IqError::ServerError {
797 code: CONFLICT_STATUS_CODE,
798 ..
799 } => GroupError::DescriptionConflict,
800 other => other.into(),
801 })
802 }
803
804 async fn query_description_id(&self, jid: &Jid) -> Result<Option<String>, GroupError> {
814 match self.client.execute(GroupQueryIq::new(jid)).await? {
815 GroupInfoOutcome::Full(group) => Ok(group.description_id),
816 GroupInfoOutcome::NotModified => Err(GroupError::InvalidRequest(
817 "group query returned not-modified without a phash".into(),
818 )),
819 }
820 }
821
822 pub async fn leave(&self, jid: impl Into<Jid>) -> Result<(), GroupError> {
823 let jid = &jid.into();
824 self.client.execute(LeaveGroupIq::new(jid)).await?;
825 self.client
826 .lock_group_metadata(jid)
827 .await
828 .invalidate()
829 .await;
830 Ok(())
831 }
832
833 pub async fn add_participants(
834 &self,
835 jid: impl Into<Jid>,
836 participants: &[Jid],
837 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
838 let jid = &jid.into();
839 let iq = if self
840 .client
841 .ab_props()
842 .is_enabled(wacore::iq::abprops::web::PRIVACY_TOKEN_SENDING_ON_GROUP_PARTICIPANT_ADD)
843 .await
844 {
845 let options = self.resolve_participant_tokens(participants).await;
846 AddParticipantsIq::with_options(jid, options)
847 } else {
848 AddParticipantsIq::new(jid, participants)
849 };
850
851 let result = self.client.execute(iq).await?;
852 if result.iter().any(|r| r.is_ok()) {
853 let metadata = self.client.lock_group_metadata(jid).await;
854 if let Some(info) = metadata.current().await {
855 let mut info = Arc::unwrap_or_clone(info);
856 info.add_participants(
857 result
858 .iter()
859 .filter(|r| r.is_ok())
860 .map(|r| (&r.jid, r.phone_number.as_ref())),
861 );
862 metadata.publish(Arc::new(info)).await;
863 } else {
864 metadata.invalidate().await;
866 }
867 }
868 Ok(result)
869 }
870
871 pub async fn remove_participants(
872 &self,
873 jid: impl Into<Jid>,
874 participants: &[Jid],
875 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
876 let jid = &jid.into();
877 let result = self
878 .client
879 .execute(RemoveParticipantsIq::new(jid, participants))
880 .await?;
881 self.apply_participant_removals(jid, &result, ParticipantRemovalScope::Group)
882 .await;
883 Ok(result)
884 }
885
886 async fn apply_participant_removals(
887 &self,
888 jid: &Jid,
889 result: &[ParticipantChangeResponse],
890 scope: ParticipantRemovalScope,
891 ) {
892 let accepted: Vec<&str> = result
893 .iter()
894 .filter(|r| r.is_ok())
895 .map(|r| r.jid.user.as_str())
896 .collect();
897 if !accepted.is_empty() {
898 match scope {
899 ParticipantRemovalScope::Group => {
900 let metadata = self.client.lock_group_metadata(jid).await;
901 if let Some(info) = metadata.current().await {
902 let mut info = Arc::unwrap_or_clone(info);
903 info.remove_participants(&accepted);
904 metadata.publish(Arc::new(info)).await;
905 } else {
906 metadata.invalidate().await;
908 }
909 }
910 ParticipantRemovalScope::LinkedGroups => {
911 self.client
918 .lock_group_metadata(jid)
919 .await
920 .invalidate()
921 .await;
922 }
923 }
924 self.client
925 .rotate_sender_key_on_participant_remove(jid, &accepted)
926 .await;
927 }
928 }
929
930 pub async fn remove_participants_including_linked_groups(
932 &self,
933 jid: impl Into<Jid>,
934 participants: &[Jid],
935 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
936 let jid = &jid.into();
937 let result = self
938 .client
939 .execute(RemoveParticipantsIncludingLinkedGroupsIq::new(
940 jid,
941 participants,
942 ))
943 .await?;
944 self.apply_participant_removals(jid, &result, ParticipantRemovalScope::LinkedGroups)
945 .await;
946 Ok(result)
947 }
948
949 pub async fn promote_participants(
950 &self,
951 jid: impl Into<Jid>,
952 participants: &[Jid],
953 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
954 let jid = &jid.into();
955 Ok(self
956 .client
957 .execute(PromoteParticipantsIq::new(jid, participants))
958 .await?)
959 }
960
961 pub async fn demote_participants(
962 &self,
963 jid: impl Into<Jid>,
964 participants: &[Jid],
965 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
966 let jid = &jid.into();
967 Ok(self
968 .client
969 .execute(DemoteParticipantsIq::new(jid, participants))
970 .await?)
971 }
972
973 pub async fn get_invite_link(
974 &self,
975 jid: impl Into<Jid>,
976 reset: bool,
977 ) -> Result<String, GroupError> {
978 let jid = &jid.into();
979 Ok(self
980 .client
981 .execute(GetGroupInviteLinkIq::new(jid, reset))
982 .await?)
983 }
984
985 pub async fn set_locked(&self, jid: impl Into<Jid>, locked: bool) -> Result<(), GroupError> {
987 let jid = &jid.into();
988 let spec = if locked {
989 SetGroupLockedIq::lock(jid)
990 } else {
991 SetGroupLockedIq::unlock(jid)
992 };
993 Ok(self.client.execute(spec).await?)
994 }
995
996 pub async fn set_announce(
998 &self,
999 jid: impl Into<Jid>,
1000 announce: bool,
1001 ) -> Result<(), GroupError> {
1002 let jid = &jid.into();
1003 let spec = if announce {
1004 SetGroupAnnouncementIq::announce(jid)
1005 } else {
1006 SetGroupAnnouncementIq::unannounce(jid)
1007 };
1008 Ok(self.client.execute(spec).await?)
1009 }
1010
1011 pub async fn set_ephemeral(
1016 &self,
1017 jid: impl Into<Jid>,
1018 expiration: u32,
1019 ) -> Result<(), GroupError> {
1020 let jid = &jid.into();
1021 let spec = match std::num::NonZeroU32::new(expiration) {
1022 Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
1023 None => SetGroupEphemeralIq::disable(jid),
1024 };
1025 Ok(self.client.execute(spec).await?)
1026 }
1027
1028 pub async fn set_membership_approval(
1030 &self,
1031 jid: impl Into<Jid>,
1032 mode: MembershipApprovalMode,
1033 ) -> Result<(), GroupError> {
1034 let jid = &jid.into();
1035 Ok(self
1036 .client
1037 .execute(SetGroupMembershipApprovalIq::new(jid, mode))
1038 .await?)
1039 }
1040
1041 pub async fn join_with_invite_code(&self, code: &str) -> Result<JoinGroupResult, GroupError> {
1043 let code = extract_invite_code(code)
1044 .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?;
1045 Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
1046 }
1047
1048 pub async fn join_with_invite_v4(
1050 &self,
1051 group_jid: impl Into<Jid>,
1052 code: &str,
1053 expiration: i64,
1054 admin_jid: impl Into<Jid>,
1055 ) -> Result<JoinGroupResult, GroupError> {
1056 let group_jid = &group_jid.into();
1057 let admin_jid = &admin_jid.into();
1058 if expiration > 0 {
1059 let now = wacore::time::now_millis() / 1000;
1060 if expiration < now {
1061 return Err(GroupError::InvalidRequest(format!(
1062 "V4 invite has expired (expiration={expiration}, now={now})"
1063 )));
1064 }
1065 }
1066 Ok(self
1067 .client
1068 .execute(AcceptGroupInviteV4Iq::new(
1069 group_jid, code, expiration, admin_jid,
1070 ))
1071 .await?)
1072 }
1073
1074 pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, GroupError> {
1076 let code = extract_invite_code(code)
1077 .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?;
1078 let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
1079 Ok(GroupMetadata::from(group))
1080 }
1081
1082 pub async fn get_membership_requests(
1084 &self,
1085 jid: impl Into<Jid>,
1086 ) -> Result<Vec<MembershipRequest>, GroupError> {
1087 let jid = &jid.into();
1088 Ok(self
1089 .client
1090 .execute(GetMembershipRequestsIq::new(jid))
1091 .await?)
1092 }
1093
1094 pub async fn approve_membership_requests(
1096 &self,
1097 jid: impl Into<Jid>,
1098 participants: &[Jid],
1099 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1100 let jid = &jid.into();
1101 Ok(self
1102 .client
1103 .execute(MembershipRequestActionIq::approve(jid, participants))
1104 .await?)
1105 }
1106
1107 pub async fn reject_membership_requests(
1109 &self,
1110 jid: impl Into<Jid>,
1111 participants: &[Jid],
1112 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1113 let jid = &jid.into();
1114 Ok(self
1115 .client
1116 .execute(MembershipRequestActionIq::reject(jid, participants))
1117 .await?)
1118 }
1119
1120 pub async fn set_member_add_mode(
1122 &self,
1123 jid: impl Into<Jid>,
1124 mode: MemberAddMode,
1125 ) -> Result<(), GroupError> {
1126 let jid = &jid.into();
1127 Ok(self
1128 .client
1129 .execute(SetMemberAddModeIq::new(jid, mode))
1130 .await?)
1131 }
1132
1133 pub async fn set_no_frequently_forwarded(
1135 &self,
1136 jid: impl Into<Jid>,
1137 restrict: bool,
1138 ) -> Result<(), GroupError> {
1139 let jid = &jid.into();
1140 Ok(self
1141 .client
1142 .execute(SetNoFrequentlyForwardedIq::new(jid, restrict))
1143 .await?)
1144 }
1145
1146 pub async fn set_allow_admin_reports(
1148 &self,
1149 jid: impl Into<Jid>,
1150 allow: bool,
1151 ) -> Result<(), GroupError> {
1152 let jid = &jid.into();
1153 Ok(self
1154 .client
1155 .execute(SetAllowAdminReportsIq::new(jid, allow))
1156 .await?)
1157 }
1158
1159 pub async fn set_group_history(
1161 &self,
1162 jid: impl Into<Jid>,
1163 enabled: bool,
1164 ) -> Result<(), GroupError> {
1165 let jid = &jid.into();
1166 Ok(self
1167 .client
1168 .execute(SetGroupHistoryIq::new(jid, enabled))
1169 .await?)
1170 }
1171
1172 pub async fn set_member_link_mode(
1174 &self,
1175 jid: &Jid,
1176 mode: MemberLinkMode,
1177 ) -> Result<(), GroupError> {
1178 let value = match mode {
1179 MemberLinkMode::AdminLink => "ADMIN_LINK",
1180 MemberLinkMode::AllMemberLink => "ALL_MEMBER_LINK",
1181 };
1182 Ok(self
1183 .mex_update_group_property(jid, GroupPropertyUpdate::MemberLinkMode(value))
1184 .await?)
1185 }
1186
1187 pub async fn set_member_share_history_mode(
1189 &self,
1190 jid: &Jid,
1191 mode: MemberShareHistoryMode,
1192 ) -> Result<(), GroupError> {
1193 let value = match mode {
1194 MemberShareHistoryMode::AdminShare => "ADMIN_SHARE",
1195 MemberShareHistoryMode::AllMemberShare => "ALL_MEMBER_SHARE",
1196 };
1197 Ok(self
1198 .mex_update_group_property(jid, GroupPropertyUpdate::MemberShareGroupHistoryMode(value))
1199 .await?)
1200 }
1201
1202 pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), GroupError> {
1204 Ok(self
1205 .mex_update_group_property(
1206 jid,
1207 GroupPropertyUpdate::LimitSharing(LimitSharingUpdate {
1208 limit_sharing_enabled: enabled,
1209 limit_sharing_trigger: "CHAT_SETTING",
1210 }),
1211 )
1212 .await?)
1213 }
1214
1215 pub async fn cancel_membership_requests(
1217 &self,
1218 jid: impl Into<Jid>,
1219 participants: &[Jid],
1220 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1221 let jid = &jid.into();
1222 Ok(self
1223 .client
1224 .execute(CancelMembershipRequestsIq::new(jid, participants))
1225 .await?)
1226 }
1227
1228 pub async fn revoke_request_code(
1230 &self,
1231 jid: impl Into<Jid>,
1232 participants: &[Jid],
1233 ) -> Result<Vec<ParticipantChangeResponse>, GroupError> {
1234 let jid = &jid.into();
1235 Ok(self
1236 .client
1237 .execute(RevokeRequestCodeIq::new(jid, participants))
1238 .await?)
1239 }
1240
1241 pub async fn acknowledge(&self, jid: impl Into<Jid>) -> Result<(), GroupError> {
1243 let jid = &jid.into();
1244 Ok(self.client.execute(AcknowledgeGroupIq::new(jid)).await?)
1245 }
1246
1247 pub async fn batch_get_info(
1249 &self,
1250 jids: Vec<Jid>,
1251 ) -> Result<Vec<BatchGroupResult>, GroupError> {
1252 if jids.len() > wacore::iq::groups::BATCH_GROUP_INFO_LIMIT {
1253 return Err(GroupError::InvalidRequest(format!(
1254 "batch_get_info: {} groups exceeds limit of {}",
1255 jids.len(),
1256 wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
1257 )));
1258 }
1259 let raw = self.client.execute(BatchGetGroupInfoIq::new(&jids)).await?;
1260 Ok(raw
1261 .into_iter()
1262 .map(|r| match r {
1263 RawBatchResult::Full(info) => {
1264 BatchGroupResult::Full(Box::new(GroupMetadata::from(*info)))
1265 }
1266 RawBatchResult::Truncated { id, size } => BatchGroupResult::Truncated { id, size },
1267 RawBatchResult::Forbidden(id) => BatchGroupResult::Forbidden(id),
1268 RawBatchResult::NotFound(id) => BatchGroupResult::NotFound(id),
1269 })
1270 .collect())
1271 }
1272
1273 pub async fn get_profile_pictures(
1275 &self,
1276 group_jids: Vec<Jid>,
1277 picture_type: PictureType,
1278 ) -> Result<Vec<GroupProfilePicture>, GroupError> {
1279 if group_jids.len() > wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT {
1280 return Err(GroupError::InvalidRequest(format!(
1281 "get_profile_pictures: {} groups exceeds limit of {}",
1282 group_jids.len(),
1283 wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
1284 )));
1285 }
1286 let groups: Vec<(Jid, PictureType)> = group_jids
1287 .into_iter()
1288 .map(|jid| (jid, picture_type))
1289 .collect();
1290 Ok(self
1291 .client
1292 .execute(GetGroupProfilePicturesIq::with_type(&groups))
1293 .await?)
1294 }
1295
1296 pub async fn set_profile_picture(
1309 &self,
1310 group_jid: impl Into<Jid>,
1311 image_data: Vec<u8>,
1312 ) -> Result<SetProfilePictureResponse, GroupError> {
1313 let group_jid = &group_jid.into();
1314 Ok(self
1315 .client
1316 .execute(SetProfilePictureSpec::for_group(group_jid, image_data))
1317 .await?)
1318 }
1319
1320 pub async fn remove_profile_picture(
1322 &self,
1323 group_jid: impl Into<Jid>,
1324 ) -> Result<SetProfilePictureResponse, GroupError> {
1325 let group_jid = &group_jid.into();
1326 Ok(self
1327 .client
1328 .execute(SetProfilePictureSpec::remove_group(group_jid))
1329 .await?)
1330 }
1331
1332 async fn mex_update_group_property(
1333 &self,
1334 jid: &Jid,
1335 update: GroupPropertyUpdate,
1336 ) -> Result<(), MexError> {
1337 let resp = self
1338 .client
1339 .mex()
1340 .mutate(mex_request!(
1341 update_group_property,
1342 UpdateGroupPropertyVars {
1343 group_id: jid.to_string(),
1344 update,
1345 }
1346 ))
1347 .await?;
1348
1349 let state = resp
1350 .data
1351 .as_ref()
1352 .and_then(|d| d.get("xwa2_group_update_property"))
1353 .and_then(|r| r.get("state"))
1354 .and_then(|s| s.as_str());
1355
1356 if state != Some("ACTIVE") {
1357 return Err(MexError::PayloadParsing(format!(
1358 "group property update failed, state: {state:?}"
1359 )));
1360 }
1361
1362 Ok(())
1363 }
1364
1365 pub async fn update_member_label(
1370 &self,
1371 group_jid: impl Into<Jid>,
1372 label: impl Into<String>,
1373 ) -> Result<(), GroupError> {
1374 self.update_member_label_with_id(group_jid, label)
1375 .await
1376 .map(|_| ())
1377 }
1378
1379 pub async fn update_member_label_with_id(
1381 &self,
1382 group_jid: impl Into<Jid>,
1383 label: impl Into<String>,
1384 ) -> Result<String, GroupError> {
1385 let group_jid = &group_jid.into();
1386 if !group_jid.is_group() {
1387 return Err(GroupError::InvalidRequest(format!(
1388 "update_member_label requires a group JID, got {group_jid}"
1389 )));
1390 }
1391 let msg = wacore::send::build_member_label_message(label.into(), wacore::time::now_secs());
1392 let (_edit, meta) = crate::send::infer_stanza_metadata(&msg);
1397 let message_id = self.client.generate_message_id();
1398 self.client
1399 .send_message_impl(
1400 group_jid.clone(),
1401 &msg,
1402 crate::send::SendPipelineOptions {
1403 request_id: Some(&message_id),
1404 extra_stanza_nodes: meta.into_iter().collect(),
1405 ..Default::default()
1406 },
1407 )
1408 .await?;
1409 Ok(message_id)
1410 }
1411
1412 async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec<GroupParticipantOptions> {
1413 if jids.is_empty() {
1414 return Vec::new();
1415 }
1416 let only_lid = self.only_check_lid().await;
1417 let futs = jids.iter().map(|jid| async move {
1418 let mut opt = GroupParticipantOptions::new(jid.clone());
1419 if let Some(token_key) = self.resolve_token_key(jid, only_lid).await
1420 && let Some(token) = self.lookup_valid_token(&token_key).await
1421 {
1422 opt = opt.with_privacy(token);
1423 }
1424 opt
1425 });
1426 futures::future::join_all(futs).await
1427 }
1428
1429 async fn attach_tokens_to_participants(&self, participants: &mut [GroupParticipantOptions]) {
1431 if participants.is_empty() {
1432 return;
1433 }
1434 let only_lid = self.only_check_lid().await;
1435 let futs = participants.iter().enumerate().map(|(i, p)| async move {
1436 if p.privacy.is_some() {
1437 return (i, None);
1438 }
1439 let Some(token_key) = self.resolve_token_key(&p.jid, only_lid).await else {
1440 log::debug!(
1441 target: "Client/Groups",
1442 "No LID mapping for participant {}, skipping privacy attachment",
1443 p.jid
1444 );
1445 return (i, None);
1446 };
1447 let token = self.lookup_valid_token(&token_key).await;
1448 if token.is_none() {
1449 log::debug!(
1450 target: "Client/Groups",
1451 "No valid tc_token for participant {} (key={}), skipping privacy attachment",
1452 p.jid, token_key
1453 );
1454 }
1455 (i, token)
1456 });
1457 for (i, token) in futures::future::join_all(futs).await {
1458 if token.is_some() {
1459 participants[i].privacy = token;
1460 }
1461 }
1462 }
1463
1464 async fn only_check_lid(&self) -> bool {
1465 self.client
1466 .ab_props()
1467 .is_enabled(wacore::iq::props::stale::PRIVACY_TOKEN_ONLY_CHECK_LID)
1468 .await
1469 }
1470
1471 async fn resolve_token_key(
1474 &self,
1475 jid: &Jid,
1476 only_lid: bool,
1477 ) -> Option<wacore_binary::CompactString> {
1478 if jid.is_lid() {
1479 Some(jid.user.clone())
1480 } else {
1481 let lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await;
1482 if only_lid {
1483 lid
1484 } else {
1485 Some(lid.unwrap_or_else(|| jid.user.clone()))
1486 }
1487 }
1488 }
1489
1490 async fn lookup_valid_token(&self, token_key: &str) -> Option<Vec<u8>> {
1492 use wacore::iq::tctoken::is_tc_token_expired_with;
1493 let tc_config = self.client.tc_token_config().await;
1494 let backend = self.client.persistence_manager.backend();
1495 match backend.get_tc_token(token_key).await {
1496 Ok(Some(entry))
1497 if !entry.token.is_empty()
1498 && !is_tc_token_expired_with(entry.token_timestamp, &tc_config) =>
1499 {
1500 Some(entry.token)
1501 }
1502 Ok(_) => None,
1503 Err(e) => {
1504 log::warn!(
1505 target: "Client/Groups",
1506 "Failed to get tc_token for {}: {e}",
1507 token_key
1508 );
1509 None
1510 }
1511 }
1512 }
1513}
1514
1515impl Client {
1516 pub fn groups(&self) -> Groups<'_> {
1517 Groups::new(self)
1518 }
1519
1520 pub(crate) async fn lock_group_metadata<'a>(&'a self, jid: &'a Jid) -> GroupMetadataGuard<'a> {
1521 GroupMetadataGuard {
1522 client: self,
1523 jid,
1524 _guard: self.group_distribution_lock(jid).await,
1525 }
1526 }
1527}
1528
1529fn extract_invite_code(input: &str) -> Option<&str> {
1538 let input = input.trim();
1539
1540 if let Some(code) = extract_code_param(input) {
1542 return Some(code);
1543 }
1544
1545 let stripped = input
1547 .strip_prefix("https://chat.whatsapp.com/")
1548 .or_else(|| input.strip_prefix("http://chat.whatsapp.com/"));
1549
1550 let code = if let Some(path) = stripped {
1551 let path = path.strip_prefix("invite/").unwrap_or(path);
1552 path.split('?').next().unwrap_or(path).trim_end_matches('/')
1553 } else if input.contains("://") || input.contains('?') {
1554 return None;
1556 } else {
1557 input.trim_end_matches('/')
1558 };
1559
1560 if code.is_empty() { None } else { Some(code) }
1561}
1562
1563fn extract_code_param(input: &str) -> Option<&str> {
1564 let query = input.split('?').nth(1)?;
1565 for pair in query.split('&') {
1566 if let Some(val) = pair.strip_prefix("code=") {
1567 let val = val.trim_end_matches('/');
1568 if !val.is_empty() {
1569 return Some(val);
1570 }
1571 }
1572 }
1573 None
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579
1580 #[test]
1581 fn test_group_metadata_struct() {
1582 let jid: Jid = "123456789@g.us"
1583 .parse()
1584 .expect("test group JID should be valid");
1585 let participant_jid: Jid = "1234567890@s.whatsapp.net"
1586 .parse()
1587 .expect("test participant JID should be valid");
1588
1589 let metadata = GroupMetadata {
1590 id: jid.clone(),
1591 subject: "Test Group".to_string(),
1592 participants: vec![GroupParticipant {
1593 jid: participant_jid,
1594 phone_number: None,
1595 lid: None,
1596 username: None,
1597 participant_type: ParticipantType::Admin,
1598 details: None,
1599 }],
1600 ..Default::default()
1601 };
1602
1603 assert_eq!(metadata.subject, "Test Group");
1604 assert_eq!(metadata.participants.len(), 1);
1605 assert!(metadata.participants[0].is_admin());
1606 assert!(!metadata.participants[0].is_super_admin());
1607 }
1608
1609 #[tokio::test]
1610 async fn fill_participant_pns_backfills_from_cache() {
1611 use crate::lid_pn_cache::{LearningSource, LidPnEntry};
1612 use wacore_binary::jid::{Jid, Server};
1613
1614 let client = crate::test_utils::create_test_client().await;
1615 let entry = LidPnEntry::new(
1618 "26263000000099".to_string(),
1619 "5521900000099".to_string(),
1620 LearningSource::Usync,
1621 );
1622 client.lid_pn_cache.add(&entry).await;
1623
1624 let mut meta = GroupMetadata {
1625 id: "120399@g.us".parse().unwrap(),
1626 participants: vec![GroupParticipant {
1627 jid: Jid::new("26263000000099", Server::Lid),
1628 phone_number: None,
1629 lid: None,
1630 username: None,
1631 participant_type: ParticipantType::Member,
1632 details: None,
1633 }],
1634 addressing_mode: AddressingMode::Lid,
1635 ..Default::default()
1636 };
1637 client.groups().fill_participant_pns(&mut meta).await;
1638 assert_eq!(
1639 meta.participants[0].phone_number,
1640 Some(Jid::pn("5521900000099")),
1641 "LID participant should receive its PN from the warm cache"
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn fill_participant_pns_noop_in_pn_group() {
1647 use wacore_binary::jid::{Jid, Server};
1648
1649 let client = crate::test_utils::create_test_client().await;
1650 let mut meta = GroupMetadata {
1652 id: "120398@g.us".parse().unwrap(),
1653 participants: vec![GroupParticipant {
1654 jid: Jid::new("5521900000098", Server::Pn),
1655 phone_number: None,
1656 lid: None,
1657 username: None,
1658 participant_type: ParticipantType::Member,
1659 details: None,
1660 }],
1661 addressing_mode: AddressingMode::Pn,
1662 ..Default::default()
1663 };
1664 client.groups().fill_participant_pns(&mut meta).await;
1665 assert_eq!(meta.participants[0].phone_number, None);
1666 }
1667
1668 #[test]
1669 fn test_extract_invite_code() {
1670 assert_eq!(
1672 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh").unwrap(),
1673 "AbCdEfGh"
1674 );
1675 assert_eq!(
1676 extract_invite_code("http://chat.whatsapp.com/AbCdEfGh").unwrap(),
1677 "AbCdEfGh"
1678 );
1679
1680 assert_eq!(
1682 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh?fbclid=123&utm_source=x")
1683 .unwrap(),
1684 "AbCdEfGh"
1685 );
1686
1687 assert_eq!(
1689 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh/").unwrap(),
1690 "AbCdEfGh"
1691 );
1692
1693 assert_eq!(
1695 extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh").unwrap(),
1696 "AbCdEfGh"
1697 );
1698 assert_eq!(
1699 extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh?utm=test").unwrap(),
1700 "AbCdEfGh"
1701 );
1702
1703 assert_eq!(
1705 extract_invite_code("https://web.whatsapp.com/accept?code=AbCdEfGh").unwrap(),
1706 "AbCdEfGh"
1707 );
1708 assert_eq!(
1709 extract_invite_code("https://web.whatsapp.com/accept/?code=AbCdEfGh&other=1").unwrap(),
1710 "AbCdEfGh"
1711 );
1712
1713 assert_eq!(
1715 extract_invite_code("whatsapp://chat/?code=AbCdEfGh").unwrap(),
1716 "AbCdEfGh"
1717 );
1718 assert_eq!(
1719 extract_invite_code("whatsapp://chat?code=AbCdEfGh&extra=y").unwrap(),
1720 "AbCdEfGh"
1721 );
1722
1723 assert_eq!(extract_invite_code("AbCdEfGh").unwrap(), "AbCdEfGh");
1725 assert_eq!(extract_invite_code("AbCdEfGh/").unwrap(), "AbCdEfGh");
1726
1727 assert_eq!(extract_invite_code(" AbCdEfGh ").unwrap(), "AbCdEfGh");
1729
1730 assert!(extract_invite_code("").is_none());
1732 assert!(extract_invite_code(" ").is_none());
1733 assert!(extract_invite_code("https://chat.whatsapp.com/").is_none());
1734 assert!(extract_invite_code("https://chat.whatsapp.com/invite/").is_none());
1735 assert!(extract_invite_code("whatsapp://chat/?code=").is_none());
1736 assert!(extract_invite_code("whatsapp://chat/?code=&other=1").is_none());
1737 }
1738
1739 #[tokio::test]
1740 async fn warm_group_cache_hit_shares_arc_not_deep_clone() {
1741 use wacore::client::context::GroupInfo;
1742 use wacore::types::message::AddressingMode;
1743
1744 let client = crate::test_utils::create_test_client().await;
1745 let group_jid: Jid = "123456789@g.us".parse().unwrap();
1746
1747 let info = GroupInfo::new(
1748 vec![
1749 "111111111111@s.whatsapp.net".parse().unwrap(),
1750 "222222222222@s.whatsapp.net".parse().unwrap(),
1751 ],
1752 AddressingMode::Pn,
1753 );
1754 let cache = client.get_group_cache().await;
1755 cache.insert(group_jid.clone(), Arc::new(info)).await;
1756
1757 let a = cache.get(&group_jid).await.expect("warm hit");
1758 let b = cache.get(&group_jid).await.expect("warm hit");
1759
1760 assert!(Arc::ptr_eq(&a, &b));
1763 assert_eq!(a.participants.len(), 2);
1764 }
1765
1766 #[tokio::test]
1767 async fn refresh_keeps_the_previous_group_snapshot_on_source_failure() {
1768 let client = crate::test_utils::create_test_client().await;
1769 let group: Jid = "120363000000000099@g.us".parse().unwrap();
1770 let previous = Arc::new(GroupInfo::new(
1771 vec!["12025550101@s.whatsapp.net".parse().unwrap()],
1772 AddressingMode::Pn,
1773 ));
1774 let cache = client.get_group_cache().await;
1775 cache.insert(group.clone(), Arc::clone(&previous)).await;
1776
1777 let result = client
1778 .groups()
1779 .query_info_with_freshness(&group, crate::cache::Freshness::Refresh)
1780 .await;
1781 assert!(
1782 result.is_err(),
1783 "the offline fixture proves refresh consulted the source"
1784 );
1785
1786 let preserved = cache
1787 .get(&group)
1788 .await
1789 .expect("refresh failure must not clear the current snapshot");
1790 assert!(Arc::ptr_eq(&previous, &preserved));
1791 }
1792
1793 #[tokio::test]
1794 async fn linked_removal_preserves_unrelated_group_cache_entries() {
1795 use wacore::protocol::ProtocolNode;
1796 use wacore_binary::builder::NodeBuilder;
1797
1798 let client = crate::test_utils::create_test_client().await;
1799 let parent: Jid = "120363000000000001@g.us".parse().unwrap();
1800 let unrelated: Jid = "120363000000000002@g.us".parse().unwrap();
1801 let removed: Jid = "12025550103@s.whatsapp.net".parse().unwrap();
1802 let cache = client.get_group_cache().await;
1803 for jid in [&parent, &unrelated] {
1804 cache
1805 .insert(
1806 jid.clone(),
1807 Arc::new(GroupInfo::new(vec![removed.clone()], AddressingMode::Pn)),
1808 )
1809 .await;
1810 }
1811 let response = ParticipantChangeResponse::try_from_node(
1812 &NodeBuilder::new("participant")
1813 .attr("jid", &removed)
1814 .build(),
1815 )
1816 .expect("participant response should parse");
1817
1818 client
1819 .groups()
1820 .apply_participant_removals(&parent, &[response], ParticipantRemovalScope::LinkedGroups)
1821 .await;
1822
1823 assert!(cache.get(&parent).await.is_none());
1824 assert!(cache.get(&unrelated).await.is_some());
1825 }
1826
1827 #[tokio::test]
1828 async fn invalidate_persisted_group_metadata_drops_blob() {
1829 let client = crate::test_utils::create_test_client().await;
1832 let backend = client.persistence_manager.backend();
1833 let group_jid: Jid = "123456789@g.us".parse().unwrap();
1834
1835 backend
1836 .put_group_metadata(&group_jid.to_string(), b"stale-blob")
1837 .await
1838 .unwrap();
1839 assert!(
1840 backend
1841 .get_group_metadata(&group_jid.to_string())
1842 .await
1843 .unwrap()
1844 .is_some()
1845 );
1846
1847 client
1848 .lock_group_metadata(&group_jid)
1849 .await
1850 .invalidate()
1851 .await;
1852
1853 assert!(
1854 backend
1855 .get_group_metadata(&group_jid.to_string())
1856 .await
1857 .unwrap()
1858 .is_none(),
1859 "invalidation must delete the persisted blob"
1860 );
1861 }
1862
1863 #[test]
1866 fn group_property_update_serializes_to_wire() {
1867 assert_eq!(
1868 serde_json::to_value(UpdateGroupPropertyVars {
1869 group_id: "123@g.us".to_string(),
1870 update: GroupPropertyUpdate::MemberLinkMode("ADMIN_LINK"),
1871 })
1872 .unwrap(),
1873 serde_json::json!({
1874 "group_id": "123@g.us",
1875 "update": { "member_link_mode": "ADMIN_LINK" }
1876 })
1877 );
1878 assert_eq!(
1879 serde_json::to_value(GroupPropertyUpdate::MemberShareGroupHistoryMode(
1880 "ALL_MEMBER_SHARE"
1881 ))
1882 .unwrap(),
1883 serde_json::json!({ "member_share_group_history_mode": "ALL_MEMBER_SHARE" })
1884 );
1885 assert_eq!(
1886 serde_json::to_value(GroupPropertyUpdate::LimitSharing(LimitSharingUpdate {
1887 limit_sharing_enabled: true,
1888 limit_sharing_trigger: "CHAT_SETTING",
1889 }))
1890 .unwrap(),
1891 serde_json::json!({
1892 "limit_sharing": {
1893 "limit_sharing_enabled": true,
1894 "limit_sharing_trigger": "CHAT_SETTING"
1895 }
1896 })
1897 );
1898 }
1899
1900 fn description_test_group() -> Jid {
1902 "120363000000000001@g.us"
1903 .parse()
1904 .expect("test group JID should be valid")
1905 }
1906
1907 fn group_result_with_description(
1910 request_id: &str,
1911 group: &Jid,
1912 description_id: Option<&str>,
1913 ) -> wacore_binary::Node {
1914 use wacore_binary::builder::NodeBuilder;
1915
1916 let mut group_node = NodeBuilder::new("group")
1917 .attr("id", group.to_string())
1918 .attr("subject", "Test Group");
1919 if let Some(description_id) = description_id {
1920 group_node = group_node.children([NodeBuilder::new("description")
1921 .attr("id", description_id)
1922 .children([NodeBuilder::new("body")
1923 .string_content("current description")
1924 .build()])
1925 .build()]);
1926 }
1927
1928 NodeBuilder::new("iq")
1929 .attr("type", "result")
1930 .attr("id", request_id)
1931 .attr("from", group)
1932 .children([group_node.build()])
1933 .build()
1934 }
1935
1936 fn iq_error(request_id: &str, group: &Jid, code: &str, text: &str) -> wacore_binary::Node {
1937 use wacore_binary::builder::NodeBuilder;
1938
1939 NodeBuilder::new("iq")
1940 .attr("type", "error")
1941 .attr("id", request_id)
1942 .attr("from", group)
1943 .children([NodeBuilder::new("error")
1944 .attr("code", code)
1945 .attr("text", text)
1946 .build()])
1947 .build()
1948 }
1949
1950 fn iq_result(request_id: &str, group: &Jid) -> wacore_binary::Node {
1951 use wacore_binary::builder::NodeBuilder;
1952
1953 NodeBuilder::new("iq")
1954 .attr("type", "result")
1955 .attr("id", request_id)
1956 .attr("from", group)
1957 .build()
1958 }
1959
1960 #[tokio::test]
1964 async fn set_description_sends_the_current_description_id_as_prev() {
1965 let (client, transport) = crate::test_utils::create_iq_test_client().await;
1966 let group = description_test_group();
1967
1968 let update = {
1969 let client = Arc::clone(&client);
1970 let group = group.clone();
1971 tokio::spawn(async move {
1972 client
1973 .groups()
1974 .set_description(
1975 group,
1976 Some(GroupDescription::new("new description").unwrap()),
1977 PreviousDescription::Resolve,
1978 )
1979 .await
1980 })
1981 };
1982
1983 let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
1984 let query = query.get();
1985 assert_eq!(query.tag.as_ref(), "iq");
1986 assert_eq!(
1987 query.attrs().optional_string("type").as_deref(),
1988 Some("get")
1989 );
1990 assert!(
1991 query.get_optional_child("query").is_some(),
1992 "the resolution step must be a group metadata query"
1993 );
1994 let query_id = query
1995 .attrs()
1996 .optional_string("id")
1997 .expect("query carries an id")
1998 .into_owned();
1999 crate::test_utils::answer_iq(
2000 &client,
2001 &query_id,
2002 &group_result_with_description(&query_id, &group, Some("D1D2D3D4")),
2003 )
2004 .await;
2005
2006 let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2007 let set = set.get();
2008 assert_eq!(set.attrs().optional_string("type").as_deref(), Some("set"));
2009 let description = set
2010 .get_optional_child("description")
2011 .expect("the update carries a <description>");
2012 let id = description
2013 .attrs()
2014 .optional_string("id")
2015 .expect("a new id is minted");
2016 assert_eq!(id.len(), 8);
2017 assert_ne!(id, "D1D2D3D4", "the new id must not reuse prev");
2018 assert_eq!(
2019 description.attrs().optional_string("prev").as_deref(),
2020 Some("D1D2D3D4"),
2021 "the update must name the description it replaces"
2022 );
2023 let body = description
2024 .get_optional_child("body")
2025 .expect("a set carries a <body>");
2026 assert_eq!(body.content_as_string().as_deref(), Some("new description"));
2027
2028 let set_id = set
2029 .attrs()
2030 .optional_string("id")
2031 .expect("the update carries an id")
2032 .into_owned();
2033 crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2034 update
2035 .await
2036 .expect("the update task should not panic")
2037 .expect("the update should succeed");
2038 }
2039
2040 #[tokio::test]
2043 async fn delete_description_sends_prev_alongside_the_delete_marker() {
2044 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2045 let group = description_test_group();
2046
2047 let update = {
2048 let client = Arc::clone(&client);
2049 let group = group.clone();
2050 tokio::spawn(async move {
2051 client
2052 .groups()
2053 .set_description(group, None, PreviousDescription::Resolve)
2054 .await
2055 })
2056 };
2057
2058 let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2059 let query_id = query
2060 .get()
2061 .attrs()
2062 .optional_string("id")
2063 .expect("query carries an id")
2064 .into_owned();
2065 crate::test_utils::answer_iq(
2066 &client,
2067 &query_id,
2068 &group_result_with_description(&query_id, &group, Some("AABBCCDD")),
2069 )
2070 .await;
2071
2072 let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2073 let set = set.get();
2074 let description = set
2075 .get_optional_child("description")
2076 .expect("the delete carries a <description>");
2077 assert_eq!(
2078 description.attrs().optional_string("delete").as_deref(),
2079 Some("true")
2080 );
2081 assert_eq!(
2082 description.attrs().optional_string("prev").as_deref(),
2083 Some("AABBCCDD")
2084 );
2085 assert!(
2086 description.get_optional_child("body").is_none(),
2087 "a delete carries no body"
2088 );
2089
2090 let set_id = set
2091 .attrs()
2092 .optional_string("id")
2093 .expect("the delete carries an id")
2094 .into_owned();
2095 crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2096 update
2097 .await
2098 .expect("the delete task should not panic")
2099 .expect("the delete should succeed");
2100 }
2101
2102 #[tokio::test]
2105 async fn set_description_on_a_group_without_one_omits_prev() {
2106 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2107 let group = description_test_group();
2108
2109 let update = {
2110 let client = Arc::clone(&client);
2111 let group = group.clone();
2112 tokio::spawn(async move {
2113 client
2114 .groups()
2115 .set_description(
2116 group,
2117 Some(GroupDescription::new("first description").unwrap()),
2118 PreviousDescription::Resolve,
2119 )
2120 .await
2121 })
2122 };
2123
2124 let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2125 let query_id = query
2126 .get()
2127 .attrs()
2128 .optional_string("id")
2129 .expect("query carries an id")
2130 .into_owned();
2131 crate::test_utils::answer_iq(
2132 &client,
2133 &query_id,
2134 &group_result_with_description(&query_id, &group, None),
2135 )
2136 .await;
2137
2138 let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2139 let set = set.get();
2140 let description = set
2141 .get_optional_child("description")
2142 .expect("the update carries a <description>");
2143 assert!(
2144 description.attrs().optional_string("prev").is_none(),
2145 "a group with no description must not carry a prev token"
2146 );
2147 assert!(description.attrs().optional_string("id").is_some());
2148
2149 let set_id = set
2150 .attrs()
2151 .optional_string("id")
2152 .expect("the update carries an id")
2153 .into_owned();
2154 crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2155 update
2156 .await
2157 .expect("the update task should not panic")
2158 .expect("the update should succeed");
2159 }
2160
2161 #[tokio::test]
2164 async fn set_description_with_a_known_token_skips_the_resolution_query() {
2165 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2166 let group = description_test_group();
2167
2168 let update = {
2169 let client = Arc::clone(&client);
2170 let group = group.clone();
2171 tokio::spawn(async move {
2172 client
2173 .groups()
2174 .set_description(
2175 group,
2176 Some(GroupDescription::new("known token").unwrap()),
2177 PreviousDescription::Id("KNOWNID1"),
2178 )
2179 .await
2180 })
2181 };
2182
2183 let set = crate::test_utils::decode_sent_iq(&transport, 0).await;
2184 let set = set.get();
2185 assert_eq!(
2186 set.attrs().optional_string("type").as_deref(),
2187 Some("set"),
2188 "the first stanza must be the update itself, not a query"
2189 );
2190 let description = set
2191 .get_optional_child("description")
2192 .expect("the update carries a <description>");
2193 assert_eq!(
2194 description.attrs().optional_string("prev").as_deref(),
2195 Some("KNOWNID1")
2196 );
2197
2198 let set_id = set
2199 .attrs()
2200 .optional_string("id")
2201 .expect("the update carries an id")
2202 .into_owned();
2203 crate::test_utils::answer_iq(&client, &set_id, &iq_result(&set_id, &group)).await;
2204 update
2205 .await
2206 .expect("the update task should not panic")
2207 .expect("the update should succeed");
2208 assert_eq!(transport.sent_count(), 1);
2209 }
2210
2211 #[tokio::test]
2215 async fn a_failed_resolution_sends_no_update() {
2216 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2217 let group = description_test_group();
2218
2219 let update = {
2220 let client = Arc::clone(&client);
2221 let group = group.clone();
2222 tokio::spawn(async move {
2223 client
2224 .groups()
2225 .set_description(
2226 group,
2227 Some(GroupDescription::new("new description").unwrap()),
2228 PreviousDescription::Resolve,
2229 )
2230 .await
2231 })
2232 };
2233
2234 let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2235 let query_id = query
2236 .get()
2237 .attrs()
2238 .optional_string("id")
2239 .expect("query carries an id")
2240 .into_owned();
2241 crate::test_utils::answer_iq(
2242 &client,
2243 &query_id,
2244 &iq_error(&query_id, &group, "403", "forbidden"),
2245 )
2246 .await;
2247
2248 let error = update
2249 .await
2250 .expect("the update task should not panic")
2251 .expect_err("a failed resolution must fail the update");
2252 assert!(
2253 matches!(
2254 error,
2255 GroupError::Iq(IqError::ServerError { code: 403, .. })
2256 ),
2257 "the resolution failure must surface as-is, got {error:?}"
2258 );
2259 assert_eq!(
2260 transport.sent_count(),
2261 1,
2262 "no update may be sent once resolution failed"
2263 );
2264 }
2265
2266 #[tokio::test]
2270 async fn a_concurrent_change_surfaces_as_a_description_conflict() {
2271 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2272 let group = description_test_group();
2273
2274 let update = {
2275 let client = Arc::clone(&client);
2276 let group = group.clone();
2277 tokio::spawn(async move {
2278 client
2279 .groups()
2280 .set_description(
2281 group,
2282 Some(GroupDescription::new("new description").unwrap()),
2283 PreviousDescription::Resolve,
2284 )
2285 .await
2286 })
2287 };
2288
2289 let query = crate::test_utils::decode_sent_iq(&transport, 0).await;
2290 let query_id = query
2291 .get()
2292 .attrs()
2293 .optional_string("id")
2294 .expect("query carries an id")
2295 .into_owned();
2296 crate::test_utils::answer_iq(
2297 &client,
2298 &query_id,
2299 &group_result_with_description(&query_id, &group, Some("STALE001")),
2300 )
2301 .await;
2302
2303 let set = crate::test_utils::decode_sent_iq(&transport, 1).await;
2304 let set_id = set
2305 .get()
2306 .attrs()
2307 .optional_string("id")
2308 .expect("the update carries an id")
2309 .into_owned();
2310 crate::test_utils::answer_iq(
2311 &client,
2312 &set_id,
2313 &iq_error(&set_id, &group, "409", "conflict"),
2314 )
2315 .await;
2316
2317 let error = update
2318 .await
2319 .expect("the update task should not panic")
2320 .expect_err("a conflict must fail the update");
2321 assert!(
2322 matches!(error, GroupError::DescriptionConflict),
2323 "a 409 on a description update is a conflict, got {error:?}"
2324 );
2325 }
2326
2327 #[tokio::test]
2330 async fn a_forbidden_update_is_not_reported_as_a_conflict() {
2331 let (client, transport) = crate::test_utils::create_iq_test_client().await;
2332 let group = description_test_group();
2333
2334 let update = {
2335 let client = Arc::clone(&client);
2336 let group = group.clone();
2337 tokio::spawn(async move {
2338 client
2339 .groups()
2340 .set_description(
2341 group,
2342 Some(GroupDescription::new("new description").unwrap()),
2343 PreviousDescription::Id("KNOWNID1"),
2344 )
2345 .await
2346 })
2347 };
2348
2349 let set = crate::test_utils::decode_sent_iq(&transport, 0).await;
2350 let set_id = set
2351 .get()
2352 .attrs()
2353 .optional_string("id")
2354 .expect("the update carries an id")
2355 .into_owned();
2356 crate::test_utils::answer_iq(
2357 &client,
2358 &set_id,
2359 &iq_error(&set_id, &group, "403", "forbidden"),
2360 )
2361 .await;
2362
2363 let error = update
2364 .await
2365 .expect("the update task should not panic")
2366 .expect_err("a forbidden update must fail");
2367 assert!(
2368 matches!(
2369 error,
2370 GroupError::Iq(IqError::ServerError { code: 403, .. })
2371 ),
2372 "a non-conflict refusal must keep its code, got {error:?}"
2373 );
2374 }
2375
2376 #[test]
2377 fn previous_description_from_optional_id() {
2378 assert_eq!(
2379 PreviousDescription::from(Some("ABCD1234")),
2380 PreviousDescription::Id("ABCD1234")
2381 );
2382 assert_eq!(
2383 PreviousDescription::from(None),
2384 PreviousDescription::Absent,
2385 "a group with no description resolves to no token, not to a query"
2386 );
2387 }
2388}