use crate::client::Client;
use std::collections::HashMap;
use wacore::client::context::GroupInfo;
use wacore::iq::groups::{
AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AddParticipantsIq, DemoteParticipantsIq,
GetGroupInviteInfoIq, GetGroupInviteLinkIq, GetMembershipRequestsIq, GroupCreateIq,
GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq,
MembershipRequestActionIq, PromoteParticipantsIq, RemoveParticipantsIq, SetGroupAnnouncementIq,
SetGroupDescriptionIq, SetGroupEphemeralIq, SetGroupLockedIq, SetGroupMembershipApprovalIq,
SetGroupSubjectIq, SetMemberAddModeIq, normalize_participants,
};
use wacore::types::message::AddressingMode;
use wacore_binary::jid::Jid;
pub use wacore::iq::groups::{
GroupCreateOptions, GroupDescription, GroupParticipantOptions, GroupSubject, JoinGroupResult,
MemberAddMode, MemberLinkMode, MembershipApprovalMode, MembershipRequest,
ParticipantChangeResponse,
};
#[derive(Debug, Clone)]
pub struct GroupMetadata {
pub id: Jid,
pub subject: String,
pub participants: Vec<GroupParticipant>,
pub addressing_mode: AddressingMode,
pub creator: Option<Jid>,
pub creation_time: Option<u64>,
pub subject_time: Option<u64>,
pub subject_owner: Option<Jid>,
pub description: Option<String>,
pub description_id: Option<String>,
pub is_locked: bool,
pub is_announcement: bool,
pub ephemeral_expiration: u32,
pub membership_approval: bool,
pub member_add_mode: Option<MemberAddMode>,
pub member_link_mode: Option<MemberLinkMode>,
pub size: Option<u32>,
pub is_parent_group: bool,
pub parent_group_jid: Option<Jid>,
pub is_default_sub_group: bool,
pub is_general_chat: bool,
pub allow_non_admin_sub_group_creation: bool,
}
#[derive(Debug, Clone)]
pub struct GroupParticipant {
pub jid: Jid,
pub phone_number: Option<Jid>,
pub is_admin: bool,
}
impl From<GroupParticipantResponse> for GroupParticipant {
fn from(p: GroupParticipantResponse) -> Self {
Self {
jid: p.jid,
phone_number: p.phone_number,
is_admin: p.participant_type.is_admin(),
}
}
}
impl From<GroupInfoResponse> for GroupMetadata {
fn from(group: GroupInfoResponse) -> Self {
Self {
id: group.id,
subject: group.subject.into_string(),
participants: group.participants.into_iter().map(Into::into).collect(),
addressing_mode: group.addressing_mode,
creator: group.creator,
creation_time: group.creation_time,
subject_time: group.subject_time,
subject_owner: group.subject_owner,
description: group.description,
description_id: group.description_id,
is_locked: group.is_locked,
is_announcement: group.is_announcement,
ephemeral_expiration: group.ephemeral_expiration,
membership_approval: group.membership_approval,
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,
}
}
}
#[derive(Debug, Clone)]
pub struct CreateGroupResult {
pub gid: Jid,
}
pub struct Groups<'a> {
client: &'a Client,
}
impl<'a> Groups<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn query_info(&self, jid: &Jid) -> Result<GroupInfo, anyhow::Error> {
if let Some(cached) = self.client.get_group_cache().await.get(jid).await {
return Ok(cached);
}
let group = self.client.execute(GroupQueryIq::new(jid)).await?;
let participants: Vec<Jid> = group.participants.iter().map(|p| p.jid.clone()).collect();
let lid_to_pn_map: HashMap<String, Jid> = if group.addressing_mode == AddressingMode::Lid {
group
.participants
.iter()
.filter_map(|p| {
p.phone_number
.as_ref()
.map(|pn| (p.jid.user.clone(), pn.clone()))
})
.collect()
} else {
HashMap::new()
};
let mut info = GroupInfo::new(participants, group.addressing_mode);
if !lid_to_pn_map.is_empty() {
info.set_lid_to_pn_map(lid_to_pn_map);
}
self.client
.get_group_cache()
.await
.insert(jid.clone(), info.clone())
.await;
Ok(info)
}
pub async fn get_participating(&self) -> Result<HashMap<String, GroupMetadata>, anyhow::Error> {
let response = self.client.execute(GroupParticipatingIq::new()).await?;
let result = response
.groups
.into_iter()
.map(|group| {
let key = group.id.to_string();
let metadata = GroupMetadata::from(group);
(key, metadata)
})
.collect();
Ok(result)
}
pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, anyhow::Error> {
let group = self.client.execute(GroupQueryIq::new(jid)).await?;
Ok(GroupMetadata::from(group))
}
pub async fn create_group(
&self,
mut options: GroupCreateOptions,
) -> Result<CreateGroupResult, anyhow::Error> {
let mut resolved_participants = Vec::with_capacity(options.participants.len());
for participant in options.participants {
let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() {
let phone_number = self
.client
.get_phone_number_from_lid(&participant.jid.user)
.await
.ok_or_else(|| {
anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid)
})?;
participant.with_phone_number(Jid::pn(phone_number))
} else {
participant
};
resolved_participants.push(resolved);
}
options.participants = normalize_participants(&resolved_participants);
let gid = self.client.execute(GroupCreateIq::new(options)).await?;
Ok(CreateGroupResult { gid })
}
pub async fn set_subject(&self, jid: &Jid, subject: GroupSubject) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(SetGroupSubjectIq::new(jid, subject))
.await?)
}
pub async fn set_description(
&self,
jid: &Jid,
description: Option<GroupDescription>,
prev: Option<String>,
) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(SetGroupDescriptionIq::new(jid, description, prev))
.await?)
}
pub async fn leave(&self, jid: &Jid) -> Result<(), anyhow::Error> {
self.client.execute(LeaveGroupIq::new(jid)).await?;
self.client.get_group_cache().await.invalidate(jid).await;
Ok(())
}
pub async fn add_participants(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
let result = self
.client
.execute(AddParticipantsIq::new(jid, participants))
.await?;
let accepted: Vec<_> = result
.iter()
.filter(|r| r.status.as_deref() == Some("200"))
.map(|r| (r.jid.clone(), None))
.collect();
if !accepted.is_empty() {
let group_cache = self.client.get_group_cache().await;
if let Some(mut info) = group_cache.get(jid).await {
info.add_participants(&accepted);
group_cache.insert(jid.clone(), info).await;
}
}
Ok(result)
}
pub async fn remove_participants(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
let result = self
.client
.execute(RemoveParticipantsIq::new(jid, participants))
.await?;
let accepted: Vec<&str> = result
.iter()
.filter(|r| r.status.as_deref() == Some("200"))
.map(|r| r.jid.user.as_str())
.collect();
if !accepted.is_empty() {
let group_cache = self.client.get_group_cache().await;
if let Some(mut info) = group_cache.get(jid).await {
info.remove_participants(&accepted);
group_cache.insert(jid.clone(), info).await;
}
}
Ok(result)
}
pub async fn promote_participants(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(PromoteParticipantsIq::new(jid, participants))
.await?)
}
pub async fn demote_participants(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(DemoteParticipantsIq::new(jid, participants))
.await?)
}
pub async fn get_invite_link(&self, jid: &Jid, reset: bool) -> Result<String, anyhow::Error> {
Ok(self
.client
.execute(GetGroupInviteLinkIq::new(jid, reset))
.await?)
}
pub async fn set_locked(&self, jid: &Jid, locked: bool) -> Result<(), anyhow::Error> {
let spec = if locked {
SetGroupLockedIq::lock(jid)
} else {
SetGroupLockedIq::unlock(jid)
};
Ok(self.client.execute(spec).await?)
}
pub async fn set_announce(&self, jid: &Jid, announce: bool) -> Result<(), anyhow::Error> {
let spec = if announce {
SetGroupAnnouncementIq::announce(jid)
} else {
SetGroupAnnouncementIq::unannounce(jid)
};
Ok(self.client.execute(spec).await?)
}
pub async fn set_ephemeral(&self, jid: &Jid, expiration: u32) -> Result<(), anyhow::Error> {
let spec = match std::num::NonZeroU32::new(expiration) {
Some(exp) => SetGroupEphemeralIq::enable(jid, exp),
None => SetGroupEphemeralIq::disable(jid),
};
Ok(self.client.execute(spec).await?)
}
pub async fn set_membership_approval(
&self,
jid: &Jid,
mode: MembershipApprovalMode,
) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(SetGroupMembershipApprovalIq::new(jid, mode))
.await?)
}
pub async fn join_with_invite_code(
&self,
code: &str,
) -> Result<JoinGroupResult, anyhow::Error> {
let code = strip_invite_url(code);
Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?)
}
pub async fn join_with_invite_v4(
&self,
group_jid: &Jid,
code: &str,
expiration: i64,
admin_jid: &Jid,
) -> Result<JoinGroupResult, anyhow::Error> {
if expiration > 0 {
let now = wacore::time::now_millis() / 1000;
if expiration < now {
anyhow::bail!("V4 invite has expired (expiration={expiration}, now={now})");
}
}
Ok(self
.client
.execute(AcceptGroupInviteV4Iq::new(
group_jid.clone(),
code.to_string(),
expiration,
admin_jid.clone(),
))
.await?)
}
pub async fn get_invite_info(&self, code: &str) -> Result<GroupMetadata, anyhow::Error> {
let code = strip_invite_url(code);
let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?;
Ok(GroupMetadata::from(group))
}
pub async fn get_membership_requests(
&self,
jid: &Jid,
) -> Result<Vec<MembershipRequest>, anyhow::Error> {
Ok(self
.client
.execute(GetMembershipRequestsIq::new(jid))
.await?)
}
pub async fn approve_membership_requests(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
Ok(self
.client
.execute(MembershipRequestActionIq::approve(jid, participants))
.await?)
}
pub async fn reject_membership_requests(
&self,
jid: &Jid,
participants: &[Jid],
) -> Result<Vec<ParticipantChangeResponse>, anyhow::Error> {
Ok(self
.client
.execute(MembershipRequestActionIq::reject(jid, participants))
.await?)
}
pub async fn set_member_add_mode(
&self,
jid: &Jid,
mode: MemberAddMode,
) -> Result<(), anyhow::Error> {
Ok(self
.client
.execute(SetMemberAddModeIq::new(jid, mode))
.await?)
}
}
impl Client {
pub fn groups(&self) -> Groups<'_> {
Groups::new(self)
}
}
fn strip_invite_url(code: &str) -> &str {
let code = code.trim().trim_end_matches('/');
code.strip_prefix("https://chat.whatsapp.com/")
.or_else(|| code.strip_prefix("http://chat.whatsapp.com/"))
.unwrap_or(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_group_metadata_struct() {
let jid: Jid = "123456789@g.us"
.parse()
.expect("test group JID should be valid");
let participant_jid: Jid = "1234567890@s.whatsapp.net"
.parse()
.expect("test participant JID should be valid");
let metadata = GroupMetadata {
id: jid.clone(),
subject: "Test Group".to_string(),
participants: vec![GroupParticipant {
jid: participant_jid,
phone_number: None,
is_admin: true,
}],
addressing_mode: AddressingMode::Pn,
creator: None,
creation_time: None,
subject_time: None,
subject_owner: None,
description: None,
description_id: None,
is_locked: false,
is_announcement: false,
ephemeral_expiration: 0,
membership_approval: false,
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");
assert_eq!(metadata.participants.len(), 1);
assert!(metadata.participants[0].is_admin);
}
}