1use crate::client::Client;
2use crate::features::mex::{MexError, MexRequest};
3use std::collections::HashMap;
4use wacore::client::context::GroupInfo;
5use wacore::iq::groups::{
6 AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AcknowledgeGroupIq, AddParticipantsIq,
7 BatchGetGroupInfoIq, CancelMembershipRequestsIq, DemoteParticipantsIq, GetGroupInviteInfoIq,
8 GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq,
9 GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq,
10 MembershipRequestActionIq, PromoteParticipantsIq, RemoveParticipantsIq, RevokeRequestCodeIq,
11 SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq,
12 SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq,
13 SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants,
14};
15use wacore::types::message::AddressingMode;
16use wacore_binary::{Jid, JidExt as _};
17
18use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult;
19pub use wacore::iq::groups::{
20 GroupCreateOptions, GroupDescription, GroupJoinError, GroupParticipantOptions,
21 GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult,
22 MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode,
23 MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType,
24};
25
26#[derive(Debug, Clone)]
28pub enum BatchGroupResult {
29 Full(Box<GroupMetadata>),
30 Truncated {
32 id: Jid,
33 size: Option<u32>,
34 },
35 Forbidden(Jid),
36 NotFound(Jid),
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct GroupMetadata {
41 pub id: Jid,
42 pub subject: String,
43 pub participants: Vec<GroupParticipant>,
44 pub addressing_mode: AddressingMode,
45 pub creator: Option<Jid>,
47 pub creation_time: Option<u64>,
49 pub subject_time: Option<u64>,
51 pub subject_owner: Option<Jid>,
53 pub description: Option<String>,
55 pub description_id: Option<String>,
57 pub description_owner: Option<Jid>,
59 pub description_time: Option<u64>,
61 pub is_locked: bool,
63 pub is_announcement: bool,
65 pub ephemeral_expiration: u32,
67 pub ephemeral_trigger: Option<u32>,
69 pub membership_approval: bool,
71 pub member_add_mode: Option<MemberAddMode>,
73 pub member_link_mode: Option<MemberLinkMode>,
75 pub size: Option<u32>,
77 pub is_parent_group: bool,
79 pub parent_group_jid: Option<Jid>,
81 pub is_default_sub_group: bool,
83 pub is_general_chat: bool,
85 pub allow_non_admin_sub_group_creation: bool,
87 pub no_frequently_forwarded: bool,
89 pub member_share_history_mode: Option<MemberShareHistoryMode>,
91 pub growth_locked: Option<GrowthLockInfo>,
93 pub is_suspended: bool,
95 pub allow_admin_reports: bool,
97 pub is_hidden_group: bool,
99 pub is_incognito: bool,
101 pub has_group_history: bool,
103 pub is_limit_sharing_enabled: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct GroupParticipant {
109 pub jid: Jid,
110 pub phone_number: Option<Jid>,
111 pub participant_type: ParticipantType,
112}
113
114impl GroupParticipant {
115 pub fn is_admin(&self) -> bool {
116 self.participant_type.is_admin()
117 }
118
119 pub fn is_super_admin(&self) -> bool {
120 self.participant_type == ParticipantType::SuperAdmin
121 }
122}
123
124impl From<GroupParticipantResponse> for GroupParticipant {
125 fn from(p: GroupParticipantResponse) -> Self {
126 Self {
127 jid: p.jid,
128 phone_number: p.phone_number,
129 participant_type: p.participant_type,
130 }
131 }
132}
133
134impl From<GroupInfoResponse> for GroupMetadata {
135 fn from(group: GroupInfoResponse) -> Self {
136 Self {
137 id: group.id,
138 subject: group.subject.into_string(),
139 participants: group.participants.into_iter().map(Into::into).collect(),
140 addressing_mode: group.addressing_mode,
141 creator: group.creator,
142 creation_time: group.creation_time,
143 subject_time: group.subject_time,
144 subject_owner: group.subject_owner,
145 description: group.description,
146 description_id: group.description_id,
147 description_owner: group.description_owner,
148 description_time: group.description_time,
149 is_locked: group.is_locked,
150 is_announcement: group.is_announcement,
151 ephemeral_expiration: group.ephemeral_expiration,
152 ephemeral_trigger: group.ephemeral_trigger,
153 membership_approval: group.membership_approval,
154 member_add_mode: group.member_add_mode,
155 member_link_mode: group.member_link_mode,
156 size: group.size,
157 is_parent_group: group.is_parent_group,
158 parent_group_jid: group.parent_group_jid,
159 is_default_sub_group: group.is_default_sub_group,
160 is_general_chat: group.is_general_chat,
161 allow_non_admin_sub_group_creation: group.allow_non_admin_sub_group_creation,
162 no_frequently_forwarded: group.no_frequently_forwarded,
163 member_share_history_mode: group.member_share_history_mode,
164 growth_locked: group.growth_locked,
165 is_suspended: group.is_suspended,
166 allow_admin_reports: group.allow_admin_reports,
167 is_hidden_group: group.is_hidden_group,
168 is_incognito: group.is_incognito,
169 has_group_history: group.has_group_history,
170 is_limit_sharing_enabled: group.is_limit_sharing_enabled,
171 }
172 }
173}
174
175#[derive(Debug, Clone)]
176pub struct CreateGroupResult {
177 pub metadata: GroupMetadata,
178}
179
180pub struct Groups<'a> {
181 client: &'a Client,
182}
183
184impl<'a> Groups<'a> {
185 pub(crate) fn new(client: &'a Client) -> Self {
186 Self { client }
187 }
188
189 pub async fn query_info(&self, jid: &Jid) -> Result<GroupInfo, anyhow::Error> {
190 if let Some(cached) = self.client.get_group_cache().await.get(jid).await {
191 return Ok(cached);
192 }
193
194 let group = self.client.execute(GroupQueryIq::new(jid)).await?;
195
196 let n = group.participants.len();
198 let is_lid = group.addressing_mode == AddressingMode::Lid;
199 let mut participants: Vec<Jid> = Vec::with_capacity(n);
200 let mut lid_to_pn_map: HashMap<wacore_binary::CompactString, Jid> = if is_lid {
201 HashMap::with_capacity(n)
202 } else {
203 HashMap::new()
204 };
205 for p in group.participants {
206 if is_lid && let Some(pn) = p.phone_number {
207 lid_to_pn_map.insert(p.jid.user.clone(), pn);
208 }
209 participants.push(p.jid);
210 }
211
212 if !lid_to_pn_map.is_empty()
219 && let Some(client_arc) = self.client.self_weak.get().and_then(|w| w.upgrade())
220 {
221 let mut batch: Vec<(String, String)> = Vec::with_capacity(lid_to_pn_map.len());
222 for (lid_user, pn_jid) in &lid_to_pn_map {
223 if pn_jid.is_pn() {
224 batch.push((lid_user.as_str().to_string(), pn_jid.user.to_string()));
225 }
226 }
227 client_arc
228 .learn_lid_pn_mappings_batch(
229 batch,
230 crate::lid_pn_cache::LearningSource::Other,
231 false,
232 )
233 .await;
234 }
235
236 let mut info = GroupInfo::new(participants, group.addressing_mode);
237 if !lid_to_pn_map.is_empty() {
238 info.set_lid_to_pn_map(lid_to_pn_map);
239 }
240
241 self.client
242 .get_group_cache()
243 .await
244 .insert(jid.clone(), info.clone())
245 .await;
246
247 Ok(info)
248 }
249
250 pub async fn get_participating(&self) -> Result<HashMap<String, GroupMetadata>, anyhow::Error> {
251 let response = self.client.execute(GroupParticipatingIq::new()).await?;
252
253 let result = response
254 .groups
255 .into_iter()
256 .map(|group| {
257 let key = group.id.to_string();
258 let metadata = GroupMetadata::from(group);
259 (key, metadata)
260 })
261 .collect();
262
263 Ok(result)
264 }
265
266 pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, anyhow::Error> {
267 let group = self.client.execute(GroupQueryIq::new(jid)).await?;
268 Ok(GroupMetadata::from(group))
269 }
270
271 pub async fn create_group(
272 &self,
273 mut options: GroupCreateOptions,
274 ) -> Result<CreateGroupResult, anyhow::Error> {
275 let mut resolved_participants = Vec::with_capacity(options.participants.len());
277
278 for participant in options.participants {
279 let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
280 let entry = self
281 .client
282 .get_lid_pn_entry(&participant.jid)
283 .await?
284 .ok_or_else(|| {
285 anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid)
286 })?;
287 participant.with_phone_number(Jid::pn(entry.phone_number))
288 } else {
289 participant
290 };
291 resolved_participants.push(resolved);
292 }
293
294 options.participants = normalize_participants(&resolved_participants);
295
296 if self
297 .client
298 .ab_props()
299 .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ON_GROUP_CREATE)
300 .await
301 {
302 self.attach_tokens_to_participants(&mut options.participants)
303 .await;
304 }
305
306 let group = self.client.execute(GroupCreateIq::new(options)).await?;
307
308 Ok(CreateGroupResult {
309 metadata: GroupMetadata::from(group),
310 })
311 }
312
313 pub async fn set_subject(&self, jid: &Jid, subject: GroupSubject) -> Result<(), anyhow::Error> {
314 Ok(self
315 .client
316 .execute(SetGroupSubjectIq::new(jid, subject))
317 .await?)
318 }
319
320 pub async fn set_description(
325 &self,
326 jid: &Jid,
327 description: Option<GroupDescription>,
328 prev: Option<String>,
329 ) -> Result<(), anyhow::Error> {
330 Ok(self
331 .client
332 .execute(SetGroupDescriptionIq::new(jid, description, prev))
333 .await?)
334 }
335
336 pub async fn leave(&self, jid: &Jid) -> Result<(), anyhow::Error> {
337 self.client.execute(LeaveGroupIq::new(jid)).await?;
338 self.client.get_group_cache().await.invalidate(jid).await;
339 Ok(())
340 }
341
342 pub async fn add_participants(
343 &self,
344 jid: &Jid,
345 participants: &[Jid],
346 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
347 let iq = if self
348 .client
349 .ab_props()
350 .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ON_GROUP_PARTICIPANT_ADD)
351 .await
352 {
353 let options = self.resolve_participant_tokens(participants).await;
354 AddParticipantsIq::with_options(jid, options)
355 } else {
356 AddParticipantsIq::new(jid, participants)
357 };
358
359 let result = self.client.execute(iq).await?;
360 if result.iter().any(|r| r.is_ok()) {
361 let group_cache = self.client.get_group_cache().await;
362 if let Some(mut info) = group_cache.get(jid).await {
363 info.add_participants(
364 result
365 .iter()
366 .filter(|r| r.is_ok())
367 .map(|r| (&r.jid, r.phone_number.as_ref())),
368 );
369 group_cache.insert(jid.clone(), info).await;
370 }
371 }
372 Ok(result)
373 }
374
375 pub async fn remove_participants(
376 &self,
377 jid: &Jid,
378 participants: &[Jid],
379 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
380 let result = self
381 .client
382 .execute(RemoveParticipantsIq::new(jid, participants))
383 .await?;
384 let accepted: Vec<&str> = result
385 .iter()
386 .filter(|r| r.is_ok())
387 .map(|r| r.jid.user.as_str())
388 .collect();
389 if !accepted.is_empty() {
390 let group_cache = self.client.get_group_cache().await;
391 if let Some(mut info) = group_cache.get(jid).await {
392 info.remove_participants(&accepted);
393 group_cache.insert(jid.clone(), info).await;
394 }
395 self.client
396 .rotate_sender_key_on_participant_remove(&jid.to_string(), &accepted)
397 .await;
398 }
399 Ok(result)
400 }
401
402 pub async fn promote_participants(
403 &self,
404 jid: &Jid,
405 participants: &[Jid],
406 ) -> Result<(), anyhow::Error> {
407 Ok(self
408 .client
409 .execute(PromoteParticipantsIq::new(jid, participants))
410 .await?)
411 }
412
413 pub async fn demote_participants(
414 &self,
415 jid: &Jid,
416 participants: &[Jid],
417 ) -> Result<(), anyhow::Error> {
418 Ok(self
419 .client
420 .execute(DemoteParticipantsIq::new(jid, participants))
421 .await?)
422 }
423
424 pub async fn get_invite_link(&self, jid: &Jid, reset: bool) -> Result<String, anyhow::Error> {
425 Ok(self
426 .client
427 .execute(GetGroupInviteLinkIq::new(jid, reset))
428 .await?)
429 }
430
431 pub async fn set_locked(&self, jid: &Jid, locked: bool) -> Result<(), anyhow::Error> {
433 let spec = if locked {
434 SetGroupLockedIq::lock(jid)
435 } else {
436 SetGroupLockedIq::unlock(jid)
437 };
438 Ok(self.client.execute(spec).await?)
439 }
440
441 pub async fn set_announce(&self, jid: &Jid, announce: bool) -> Result<(), anyhow::Error> {
443 let spec = if announce {
444 SetGroupAnnouncementIq::announce(jid)
445 } else {
446 SetGroupAnnouncementIq::unannounce(jid)
447 };
448 Ok(self.client.execute(spec).await?)
449 }
450
451 pub async fn set_ephemeral(&self, jid: &Jid, expiration: u32) -> Result<(), anyhow::Error> {
456 let spec = match std::num::NonZeroU32::new(expiration) {
457 Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
458 None => SetGroupEphemeralIq::disable(jid),
459 };
460 Ok(self.client.execute(spec).await?)
461 }
462
463 pub async fn set_membership_approval(
465 &self,
466 jid: &Jid,
467 mode: MembershipApprovalMode,
468 ) -> Result<(), anyhow::Error> {
469 Ok(self
470 .client
471 .execute(SetGroupMembershipApprovalIq::new(jid, mode))
472 .await?)
473 }
474
475 pub async fn join_with_invite_code(
477 &self,
478 code: &str,
479 ) -> Result<JoinGroupResult, anyhow::Error> {
480 let code = extract_invite_code(code)
481 .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?;
482 Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
483 }
484
485 pub async fn join_with_invite_v4(
487 &self,
488 group_jid: &Jid,
489 code: &str,
490 expiration: i64,
491 admin_jid: &Jid,
492 ) -> Result<JoinGroupResult, anyhow::Error> {
493 if expiration > 0 {
494 let now = wacore::time::now_millis() / 1000;
495 if expiration < now {
496 anyhow::bail!("V4 invite has expired (expiration={expiration}, now={now})");
497 }
498 }
499 Ok(self
500 .client
501 .execute(AcceptGroupInviteV4Iq::new(
502 group_jid.clone(),
503 code.to_string(),
504 expiration,
505 admin_jid.clone(),
506 ))
507 .await?)
508 }
509
510 pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, anyhow::Error> {
512 let code = extract_invite_code(code)
513 .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?;
514 let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
515 Ok(GroupMetadata::from(group))
516 }
517
518 pub async fn get_membership_requests(
520 &self,
521 jid: &Jid,
522 ) -> Result<Vec<MembershipRequest>, anyhow::Error> {
523 Ok(self
524 .client
525 .execute(GetMembershipRequestsIq::new(jid))
526 .await?)
527 }
528
529 pub async fn approve_membership_requests(
531 &self,
532 jid: &Jid,
533 participants: &[Jid],
534 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
535 Ok(self
536 .client
537 .execute(MembershipRequestActionIq::approve(jid, participants))
538 .await?)
539 }
540
541 pub async fn reject_membership_requests(
543 &self,
544 jid: &Jid,
545 participants: &[Jid],
546 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
547 Ok(self
548 .client
549 .execute(MembershipRequestActionIq::reject(jid, participants))
550 .await?)
551 }
552
553 pub async fn set_member_add_mode(
555 &self,
556 jid: &Jid,
557 mode: MemberAddMode,
558 ) -> Result<(), anyhow::Error> {
559 Ok(self
560 .client
561 .execute(SetMemberAddModeIq::new(jid, mode))
562 .await?)
563 }
564
565 pub async fn set_no_frequently_forwarded(
567 &self,
568 jid: &Jid,
569 restrict: bool,
570 ) -> Result<(), anyhow::Error> {
571 Ok(self
572 .client
573 .execute(SetNoFrequentlyForwardedIq::new(jid, restrict))
574 .await?)
575 }
576
577 pub async fn set_allow_admin_reports(
579 &self,
580 jid: &Jid,
581 allow: bool,
582 ) -> Result<(), anyhow::Error> {
583 Ok(self
584 .client
585 .execute(SetAllowAdminReportsIq::new(jid, allow))
586 .await?)
587 }
588
589 pub async fn set_group_history(&self, jid: &Jid, enabled: bool) -> Result<(), anyhow::Error> {
591 Ok(self
592 .client
593 .execute(SetGroupHistoryIq::new(jid, enabled))
594 .await?)
595 }
596
597 pub async fn set_member_link_mode(
599 &self,
600 jid: &Jid,
601 mode: MemberLinkMode,
602 ) -> Result<(), MexError> {
603 let value = match mode {
604 MemberLinkMode::AdminLink => "ADMIN_LINK",
605 MemberLinkMode::AllMemberLink => "ALL_MEMBER_LINK",
606 };
607 self.mex_update_group_property(jid, serde_json::json!({ "member_link_mode": value }))
608 .await
609 }
610
611 pub async fn set_member_share_history_mode(
613 &self,
614 jid: &Jid,
615 mode: MemberShareHistoryMode,
616 ) -> Result<(), MexError> {
617 let value = match mode {
618 MemberShareHistoryMode::AdminShare => "ADMIN_SHARE",
619 MemberShareHistoryMode::AllMemberShare => "ALL_MEMBER_SHARE",
620 };
621 self.mex_update_group_property(
622 jid,
623 serde_json::json!({ "member_share_group_history_mode": value }),
624 )
625 .await
626 }
627
628 pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), MexError> {
630 self.mex_update_group_property(
631 jid,
632 serde_json::json!({
633 "limit_sharing": {
634 "limit_sharing_enabled": enabled,
635 "limit_sharing_trigger": "CHAT_SETTING"
636 }
637 }),
638 )
639 .await
640 }
641
642 pub async fn cancel_membership_requests(
644 &self,
645 jid: &Jid,
646 participants: &[Jid],
647 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
648 Ok(self
649 .client
650 .execute(CancelMembershipRequestsIq::new(jid, participants))
651 .await?)
652 }
653
654 pub async fn revoke_request_code(
656 &self,
657 jid: &Jid,
658 participants: &[Jid],
659 ) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
660 Ok(self
661 .client
662 .execute(RevokeRequestCodeIq::new(jid, participants))
663 .await?)
664 }
665
666 pub async fn acknowledge(&self, jid: &Jid) -> Result<(), anyhow::Error> {
668 Ok(self.client.execute(AcknowledgeGroupIq::new(jid)).await?)
669 }
670
671 pub async fn batch_get_info(
673 &self,
674 jids: Vec<Jid>,
675 ) -> Result<Vec<BatchGroupResult>, anyhow::Error> {
676 anyhow::ensure!(
677 jids.len() <= wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
678 "batch_get_info: {} groups exceeds limit of {}",
679 jids.len(),
680 wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
681 );
682 let raw = self.client.execute(BatchGetGroupInfoIq::new(jids)).await?;
683 Ok(raw
684 .into_iter()
685 .map(|r| match r {
686 RawBatchResult::Full(info) => {
687 BatchGroupResult::Full(Box::new(GroupMetadata::from(*info)))
688 }
689 RawBatchResult::Truncated { id, size } => BatchGroupResult::Truncated { id, size },
690 RawBatchResult::Forbidden(id) => BatchGroupResult::Forbidden(id),
691 RawBatchResult::NotFound(id) => BatchGroupResult::NotFound(id),
692 })
693 .collect())
694 }
695
696 pub async fn get_profile_pictures(
698 &self,
699 group_jids: Vec<Jid>,
700 picture_type: PictureType,
701 ) -> Result<Vec<GroupProfilePicture>, anyhow::Error> {
702 anyhow::ensure!(
703 group_jids.len() <= wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
704 "get_profile_pictures: {} groups exceeds limit of {}",
705 group_jids.len(),
706 wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
707 );
708 let groups = group_jids
709 .into_iter()
710 .map(|jid| (jid, picture_type))
711 .collect();
712 Ok(self
713 .client
714 .execute(GetGroupProfilePicturesIq::with_type(groups))
715 .await?)
716 }
717
718 async fn mex_update_group_property(
719 &self,
720 jid: &Jid,
721 update: serde_json::Value,
722 ) -> Result<(), MexError> {
723 let resp = self
724 .client
725 .mex()
726 .mutate(MexRequest {
727 doc: wacore::iq::mex_ids::groups::UPDATE_GROUP_PROPERTY,
728 variables: serde_json::json!({
729 "group_id": jid.to_string(),
730 "update": update,
731 }),
732 })
733 .await?;
734
735 let state = resp
736 .data
737 .as_ref()
738 .and_then(|d| d.get("xwa2_group_update_property"))
739 .and_then(|r| r.get("state"))
740 .and_then(|s| s.as_str());
741
742 if state != Some("ACTIVE") {
743 return Err(MexError::PayloadParsing(format!(
744 "group property update failed, state: {state:?}"
745 )));
746 }
747
748 Ok(())
749 }
750
751 pub async fn update_member_label(
756 &self,
757 group_jid: &Jid,
758 label: impl Into<String>,
759 ) -> Result<(), anyhow::Error> {
760 if !group_jid.is_group() {
761 return Err(anyhow::anyhow!(
762 "update_member_label requires a group JID, got {group_jid}"
763 ));
764 }
765 let msg = wacore::send::build_member_label_message(label.into(), wacore::time::now_secs());
766 self.client
767 .send_message_impl(group_jid.clone(), &msg, None, false, false, None, vec![])
768 .await
769 }
770
771 async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec<GroupParticipantOptions> {
772 if jids.is_empty() {
773 return Vec::new();
774 }
775 let only_lid = self.only_check_lid().await;
776 let futs = jids.iter().map(|jid| async move {
777 let mut opt = GroupParticipantOptions::new(jid.clone());
778 if let Some(token_key) = self.resolve_token_key(jid, only_lid).await
779 && let Some(token) = self.lookup_valid_token(&token_key).await
780 {
781 opt = opt.with_privacy(token);
782 }
783 opt
784 });
785 futures::future::join_all(futs).await
786 }
787
788 async fn attach_tokens_to_participants(&self, participants: &mut [GroupParticipantOptions]) {
790 if participants.is_empty() {
791 return;
792 }
793 let only_lid = self.only_check_lid().await;
794 let futs = participants.iter().enumerate().map(|(i, p)| async move {
795 if p.privacy.is_some() {
796 return (i, None);
797 }
798 let Some(token_key) = self.resolve_token_key(&p.jid, only_lid).await else {
799 log::debug!(
800 target: "Client/Groups",
801 "No LID mapping for participant {}, skipping privacy attachment",
802 p.jid
803 );
804 return (i, None);
805 };
806 let token = self.lookup_valid_token(&token_key).await;
807 if token.is_none() {
808 log::debug!(
809 target: "Client/Groups",
810 "No valid tc_token for participant {} (key={}), skipping privacy attachment",
811 p.jid, token_key
812 );
813 }
814 (i, token)
815 });
816 for (i, token) in futures::future::join_all(futs).await {
817 if token.is_some() {
818 participants[i].privacy = token;
819 }
820 }
821 }
822
823 async fn only_check_lid(&self) -> bool {
824 self.client
825 .ab_props()
826 .is_enabled(wacore::iq::props::config_codes::PRIVACY_TOKEN_ONLY_CHECK_LID)
827 .await
828 }
829
830 async fn resolve_token_key(&self, jid: &Jid, only_lid: bool) -> Option<String> {
833 if jid.is_lid() {
834 Some(jid.user.to_string())
835 } else {
836 let lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await;
837 if only_lid {
838 lid
839 } else {
840 Some(lid.unwrap_or_else(|| jid.user.to_string()))
841 }
842 }
843 }
844
845 async fn lookup_valid_token(&self, token_key: &str) -> Option<Vec<u8>> {
847 use wacore::iq::tctoken::is_tc_token_expired_with;
848 let tc_config = self.client.tc_token_config().await;
849 let backend = self.client.persistence_manager.backend();
850 match backend.get_tc_token(token_key).await {
851 Ok(Some(entry))
852 if !entry.token.is_empty()
853 && !is_tc_token_expired_with(entry.token_timestamp, &tc_config) =>
854 {
855 Some(entry.token)
856 }
857 Ok(_) => None,
858 Err(e) => {
859 log::warn!(
860 target: "Client/Groups",
861 "Failed to get tc_token for {}: {e}",
862 token_key
863 );
864 None
865 }
866 }
867 }
868}
869
870impl Client {
871 pub fn groups(&self) -> Groups<'_> {
872 Groups::new(self)
873 }
874}
875
876fn extract_invite_code(input: &str) -> Option<&str> {
885 let input = input.trim();
886
887 if let Some(code) = extract_code_param(input) {
889 return Some(code);
890 }
891
892 let stripped = input
894 .strip_prefix("https://chat.whatsapp.com/")
895 .or_else(|| input.strip_prefix("http://chat.whatsapp.com/"));
896
897 let code = if let Some(path) = stripped {
898 let path = path.strip_prefix("invite/").unwrap_or(path);
899 path.split('?').next().unwrap_or(path).trim_end_matches('/')
900 } else if input.contains("://") || input.contains('?') {
901 return None;
903 } else {
904 input.trim_end_matches('/')
905 };
906
907 if code.is_empty() { None } else { Some(code) }
908}
909
910fn extract_code_param(input: &str) -> Option<&str> {
911 let query = input.split('?').nth(1)?;
912 for pair in query.split('&') {
913 if let Some(val) = pair.strip_prefix("code=") {
914 let val = val.trim_end_matches('/');
915 if !val.is_empty() {
916 return Some(val);
917 }
918 }
919 }
920 None
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 #[test]
928 fn test_group_metadata_struct() {
929 let jid: Jid = "123456789@g.us"
930 .parse()
931 .expect("test group JID should be valid");
932 let participant_jid: Jid = "1234567890@s.whatsapp.net"
933 .parse()
934 .expect("test participant JID should be valid");
935
936 let metadata = GroupMetadata {
937 id: jid.clone(),
938 subject: "Test Group".to_string(),
939 participants: vec![GroupParticipant {
940 jid: participant_jid,
941 phone_number: None,
942 participant_type: ParticipantType::Admin,
943 }],
944 ..Default::default()
945 };
946
947 assert_eq!(metadata.subject, "Test Group");
948 assert_eq!(metadata.participants.len(), 1);
949 assert!(metadata.participants[0].is_admin());
950 assert!(!metadata.participants[0].is_super_admin());
951 }
952
953 #[test]
954 fn test_extract_invite_code() {
955 assert_eq!(
957 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh").unwrap(),
958 "AbCdEfGh"
959 );
960 assert_eq!(
961 extract_invite_code("http://chat.whatsapp.com/AbCdEfGh").unwrap(),
962 "AbCdEfGh"
963 );
964
965 assert_eq!(
967 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh?fbclid=123&utm_source=x")
968 .unwrap(),
969 "AbCdEfGh"
970 );
971
972 assert_eq!(
974 extract_invite_code("https://chat.whatsapp.com/AbCdEfGh/").unwrap(),
975 "AbCdEfGh"
976 );
977
978 assert_eq!(
980 extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh").unwrap(),
981 "AbCdEfGh"
982 );
983 assert_eq!(
984 extract_invite_code("https://chat.whatsapp.com/invite/AbCdEfGh?utm=test").unwrap(),
985 "AbCdEfGh"
986 );
987
988 assert_eq!(
990 extract_invite_code("https://web.whatsapp.com/accept?code=AbCdEfGh").unwrap(),
991 "AbCdEfGh"
992 );
993 assert_eq!(
994 extract_invite_code("https://web.whatsapp.com/accept/?code=AbCdEfGh&other=1").unwrap(),
995 "AbCdEfGh"
996 );
997
998 assert_eq!(
1000 extract_invite_code("whatsapp://chat/?code=AbCdEfGh").unwrap(),
1001 "AbCdEfGh"
1002 );
1003 assert_eq!(
1004 extract_invite_code("whatsapp://chat?code=AbCdEfGh&extra=y").unwrap(),
1005 "AbCdEfGh"
1006 );
1007
1008 assert_eq!(extract_invite_code("AbCdEfGh").unwrap(), "AbCdEfGh");
1010 assert_eq!(extract_invite_code("AbCdEfGh/").unwrap(), "AbCdEfGh");
1011
1012 assert_eq!(extract_invite_code(" AbCdEfGh ").unwrap(), "AbCdEfGh");
1014
1015 assert!(extract_invite_code("").is_none());
1017 assert!(extract_invite_code(" ").is_none());
1018 assert!(extract_invite_code("https://chat.whatsapp.com/").is_none());
1019 assert!(extract_invite_code("https://chat.whatsapp.com/invite/").is_none());
1020 assert!(extract_invite_code("whatsapp://chat/?code=").is_none());
1021 assert!(extract_invite_code("whatsapp://chat/?code=&other=1").is_none());
1022 }
1023
1024 }