use crate::WireEnum;
use crate::iq::node::{collect_children, required_attr, required_child};
use crate::iq::spec::IqSpec;
use crate::protocol::ProtocolNode;
use crate::request::InfoQuery;
use anyhow::{Result, anyhow};
use std::num::NonZeroU32;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::{CompactString, Jid, Server};
use wacore_binary::{Node, NodeContent, NodeRef};
pub use crate::types::message::AddressingMode;
pub const GROUP_IQ_NAMESPACE: &str = "w:g2";
pub const GROUP_SUBJECT_MAX_LENGTH: usize = 100;
pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 2048;
pub const GROUP_SIZE_LIMIT: usize = 257;
pub const BATCH_GROUP_INFO_LIMIT: usize = 10_000;
pub const BATCH_PROFILE_PICTURES_LIMIT: usize = 1_000;
pub const GROUP_INFO_PARTICIPANT_LIMIT: u32 = 19_999;
pub const GROUP_EPHEMERAL_EXPIRATION_MAX: u32 = i32::MAX as u32;
pub const GROUP_SETTING_TRIGGER_MAX: u32 = 20;
pub const GROUP_EVOLUTION_VERSION_MAX: u32 = 100;
pub const EPHEMERAL_TRIGGER_MAX: u32 = GROUP_SETTING_TRIGGER_MAX;
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MemberLinkMode {
#[wire = "admin_link"]
AdminLink,
#[wire = "all_member_link"]
AllMemberLink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MemberAddMode {
#[wire = "admin_add"]
AdminAdd,
#[wire = "all_member_add"]
AllMemberAdd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MembershipApprovalMode {
#[wire_default]
#[wire = "off"]
Off,
#[wire = "on"]
On,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MemberShareHistoryMode {
#[wire_default]
#[wire = "admin_share"]
AdminShare,
#[wire = "all_member_share"]
AllMemberShare,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum GroupAppealStatus {
#[wire = "approved"]
Approved,
#[wire = "in_review"]
InReview,
#[wire = "none"]
NoAppeal,
#[wire = "rejected"]
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrowthLockInfo {
pub lock_type: String,
pub expiration: u64,
}
macro_rules! define_error_code_enum {
(
$(#[$meta:meta])*
$name:ident { $( $variant:ident = $code:literal : $desc:literal ),+ $(,)? }
) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name {
$( $variant, )+
Unknown(u16),
}
impl $name {
pub fn from_code(code: u16) -> Self {
match code {
$( $code => Self::$variant, )+
_ => Self::Unknown(code),
}
}
pub fn code(&self) -> u16 {
match self {
$( Self::$variant => $code, )+
Self::Unknown(c) => *c,
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
$( Self::$variant => write!(f, concat!($desc, " (", stringify!($code), ")")), )+
Self::Unknown(c) => write!(f, "unknown error ({c})"),
}
}
}
};
}
define_error_code_enum! {
InviteInfoError {
BadRequest = 400: "bad request",
NotAuthorized = 401: "not authorized",
NotFound = 404: "group not found",
NotAcceptable = 406: "not acceptable",
Gone = 410: "invite link was reset",
ParentGroupSuspended = 416: "parent group suspended",
Locked = 423: "group locked",
GrowthLocked = 436: "invite link unavailable",
}
}
define_error_code_enum! {
GroupJoinError {
AlreadyMember = 304: "already a member",
BadRequest = 400: "bad request",
Forbidden = 403: "forbidden",
NotFound = 404: "group not found",
NotAllowed = 405: "removed from group",
Conflict = 409: "conflict",
Gone = 410: "invite link was reset",
CommunityFull = 412: "community is full",
GroupFull = 419: "group is full",
Locked = 423: "group locked",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum GroupQueryRequestType {
#[wire_default]
#[wire = "interactive"]
Interactive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum ParticipantType {
#[wire_default]
#[wire = "member"]
Member,
#[wire = "admin"]
Admin,
#[wire = "superadmin"]
SuperAdmin,
}
impl ParticipantType {
pub fn is_admin(&self) -> bool {
matches!(self, ParticipantType::Admin | ParticipantType::SuperAdmin)
}
}
impl TryFrom<Option<&str>> for ParticipantType {
type Error = anyhow::Error;
fn try_from(value: Option<&str>) -> Result<Self> {
match value {
Some("admin") => Ok(ParticipantType::Admin),
Some("superadmin") => Ok(ParticipantType::SuperAdmin),
Some("member") | None => Ok(ParticipantType::Member),
Some(other) => Err(anyhow!("unknown participant type: {other}")),
}
}
}
crate::define_validated_string! {
pub struct GroupSubject(max_len = GROUP_SUBJECT_MAX_LENGTH, name = "Group subject")
}
crate::define_validated_string! {
pub struct GroupDescription(max_len = GROUP_DESCRIPTION_MAX_LENGTH, name = "Group description")
}
#[derive(Debug, Clone, bon::Builder)]
#[builder(finish_fn = finish)]
pub struct GroupParticipantOptions {
pub jid: Jid,
pub phone_number: Option<Jid>,
pub privacy: Option<Vec<u8>>,
}
impl<S: group_participant_options_builder::IsComplete> GroupParticipantOptionsBuilder<S> {
pub fn build<T: From<GroupParticipantOptions>>(self) -> T {
self.finish().into()
}
}
impl GroupParticipantOptions {
pub fn new(jid: Jid) -> Self {
Self {
jid,
phone_number: None,
privacy: None,
}
}
pub fn from_phone(phone_number: Jid) -> Self {
Self::new(phone_number)
}
pub fn with_phone_number(mut self, phone_number: Jid) -> Self {
self.phone_number = Some(phone_number);
self
}
pub fn with_privacy(mut self, privacy: Vec<u8>) -> Self {
self.privacy = Some(privacy);
self
}
}
#[derive(Debug, Clone, bon::Builder)]
#[builder(finish_fn = finish)]
pub struct GroupCreateOptions {
#[builder(into)]
pub subject: String,
#[builder(default)]
pub participants: Vec<GroupParticipantOptions>,
#[builder(required, default = Some(MemberLinkMode::AdminLink), with = |v: MemberLinkMode| Some(v))]
pub member_link_mode: Option<MemberLinkMode>,
#[builder(required, default = Some(MemberAddMode::AllMemberAdd), with = |v: MemberAddMode| Some(v))]
pub member_add_mode: Option<MemberAddMode>,
#[builder(required, default = Some(MembershipApprovalMode::Off), with = |v: MembershipApprovalMode| Some(v))]
pub membership_approval_mode: Option<MembershipApprovalMode>,
#[builder(required, default = Some(0), with = |v: u32| Some(v))]
pub ephemeral_expiration: Option<u32>,
#[builder(default)]
pub is_parent: bool,
#[builder(default)]
pub closed: bool,
#[builder(default)]
pub allow_non_admin_sub_group_creation: bool,
#[builder(default)]
pub create_general_chat: bool,
#[builder(into)]
pub linked_parent: Option<Jid>,
#[builder(into)]
pub description: Option<GroupDescription>,
}
impl<S: group_create_options_builder::IsComplete> GroupCreateOptionsBuilder<S> {
pub fn build<T: From<GroupCreateOptions>>(self) -> T {
self.finish().into()
}
}
impl GroupCreateOptions {
pub fn new(subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
..Default::default()
}
}
pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self {
self.participants.push(participant);
self
}
pub fn with_participants(mut self, participants: Vec<GroupParticipantOptions>) -> Self {
self.participants = participants;
self
}
pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self {
self.member_link_mode = Some(mode);
self
}
pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self {
self.member_add_mode = Some(mode);
self
}
pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self {
self.ephemeral_expiration = Some(expiration);
self
}
}
impl Default for GroupCreateOptions {
fn default() -> Self {
Self {
subject: String::new(),
participants: Vec::new(),
member_link_mode: Some(MemberLinkMode::AdminLink),
member_add_mode: Some(MemberAddMode::AllMemberAdd),
membership_approval_mode: Some(MembershipApprovalMode::Off),
ephemeral_expiration: Some(0),
is_parent: false,
closed: false,
allow_non_admin_sub_group_creation: false,
create_general_chat: false,
linked_parent: None,
description: None,
}
}
}
fn generate_description_id() -> String {
use rand::RngExt as _;
format!(
"{:08X}",
rand::make_rng::<rand::rngs::StdRng>().random::<u32>()
)
}
pub fn normalize_participants(
participants: &[GroupParticipantOptions],
) -> Vec<GroupParticipantOptions> {
participants
.iter()
.cloned()
.map(|p| {
if !p.jid.is_lid() && p.phone_number.is_some() {
GroupParticipantOptions {
phone_number: None,
..p
}
} else {
p
}
})
.collect()
}
pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
let mut children = Vec::new();
if let Some(link_mode) = &options.member_link_mode {
children.push(
NodeBuilder::new("member_link_mode")
.string_content(link_mode.as_str())
.build(),
);
}
if let Some(add_mode) = &options.member_add_mode {
children.push(
NodeBuilder::new("member_add_mode")
.string_content(add_mode.as_str())
.build(),
);
}
let participants = normalize_participants(&options.participants);
for participant in &participants {
let mut attrs = vec![("jid", participant.jid.to_string())];
if let Some(pn) = &participant.phone_number {
attrs.push(("phone_number", pn.to_string()));
}
let participant_node = if let Some(privacy_bytes) = &participant.privacy {
NodeBuilder::new("participant")
.attrs(attrs)
.children([NodeBuilder::new("privacy")
.string_content(hex::encode(privacy_bytes))
.build()])
.build()
} else {
NodeBuilder::new("participant").attrs(attrs).build()
};
children.push(participant_node);
}
if let Some(expiration) = &options.ephemeral_expiration {
children.push(
NodeBuilder::new("ephemeral")
.attr("expiration", *expiration)
.build(),
);
}
if let Some(approval_mode) = &options.membership_approval_mode {
children.push(
NodeBuilder::new("membership_approval_mode")
.children([NodeBuilder::new("group_join")
.attr("state", approval_mode.as_str())
.build()])
.build(),
);
}
debug_assert!(
options.linked_parent.is_none() || !options.is_parent,
"GroupCreateOptions: linked_parent and is_parent are mutually exclusive"
);
if let Some(parent_jid) = &options.linked_parent {
if options.is_parent {
log::warn!(
"GroupCreateOptions has both linked_parent={parent_jid} and is_parent=true \
(closed={}, allow_non_admin_sub_group_creation={}, create_general_chat={}); \
dropping parent-only flags",
options.closed,
options.allow_non_admin_sub_group_creation,
options.create_general_chat,
);
}
children.push(
NodeBuilder::new("linked_parent")
.attr("jid", parent_jid)
.build(),
);
} else if options.is_parent {
let mut parent_builder = NodeBuilder::new("parent");
if options.closed {
parent_builder =
parent_builder.attr("default_membership_approval_mode", "request_required");
}
children.push(parent_builder.build());
if options.allow_non_admin_sub_group_creation {
children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build());
}
if options.create_general_chat {
children.push(NodeBuilder::new("create_general_chat").build());
}
}
if let Some(desc) = &options.description {
children.push(
NodeBuilder::new("description")
.attr("id", generate_description_id())
.children([NodeBuilder::new("body")
.string_content(desc.as_str())
.build()])
.build(),
);
}
NodeBuilder::new("create")
.attr("subject", &options.subject)
.children(children)
.build()
}
#[derive(Debug, Clone, crate::ProtocolNode)]
#[protocol(tag = "query")]
pub struct GroupQueryRequest {
#[attr(name = "request", string_enum)]
pub request: GroupQueryRequestType,
}
fn optional_u64_attr(node: &NodeRef<'_>, name: &str) -> Result<Option<u64>> {
let Some(value) = node.attrs().optional_string(name) else {
return Ok(None);
};
value
.parse()
.map(Some)
.map_err(|error| anyhow!("invalid '{name}' attribute '{value}': {error}"))
}
fn optional_bounded_u32_attr(node: &NodeRef<'_>, name: &str, maximum: u32) -> Result<Option<u32>> {
let Some(value) = optional_u64_attr(node, name)? else {
return Ok(None);
};
let value = u32::try_from(value)
.map_err(|_| anyhow!("'{name}' attribute exceeds {maximum}: {value}"))?;
if value > maximum {
return Err(anyhow!("'{name}' attribute exceeds {maximum}: {value}"));
}
Ok(Some(value))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct GroupParticipantDetails {
pub participant_label: Option<CompactString>,
pub participant_label_mtime: Option<u64>,
pub join_time: Option<u64>,
pub group_history_sent: Option<bool>,
pub display_name: Option<CompactString>,
pub is_addressable: bool,
}
impl Default for GroupParticipantDetails {
fn default() -> Self {
Self {
participant_label: None,
participant_label_mtime: None,
join_time: None,
group_history_sent: None,
display_name: None,
is_addressable: true,
}
}
}
impl GroupParticipantDetails {
fn from_node(node: &NodeRef<'_>) -> Result<Option<Box<Self>>> {
let mut attrs = node.attrs();
let participant_label = attrs
.optional_string("participant_label")
.map(|value| CompactString::from(value.as_ref()));
let group_history_sent = attrs.optional_bool_value("group_history_sent");
let display_name = attrs
.optional_string("display_name")
.map(|value| CompactString::from(value.as_ref()));
let is_addressable = attrs.optional_bool_value("addressable").unwrap_or(true);
attrs.finish()?;
let details = Self {
participant_label,
participant_label_mtime: optional_u64_attr(node, "participant_label_mtime")?,
join_time: optional_u64_attr(node, "join_time")?,
group_history_sent,
display_name,
is_addressable,
};
Ok((details != Self::default()).then(|| Box::new(details)))
}
fn apply_to_builder(self, mut builder: NodeBuilder) -> NodeBuilder {
if let Some(value) = self.participant_label {
builder = builder.attr("participant_label", value);
}
if let Some(value) = self.participant_label_mtime {
builder = builder.attr("participant_label_mtime", value);
}
if let Some(value) = self.join_time {
builder = builder.attr("join_time", value);
}
if let Some(value) = self.group_history_sent {
builder = builder.attr("group_history_sent", if value { "true" } else { "false" });
}
if let Some(value) = self.display_name {
builder = builder.attr("display_name", value);
}
if !self.is_addressable {
builder = builder.attr("addressable", "false");
}
builder
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GroupParticipantResponse {
pub jid: Jid,
pub phone_number: Option<Jid>,
pub lid: Option<Jid>,
pub username: Option<CompactString>,
pub participant_type: ParticipantType,
pub details: Option<Box<GroupParticipantDetails>>,
}
impl ProtocolNode for GroupParticipantResponse {
fn tag(&self) -> &'static str {
"participant"
}
fn into_node(self) -> Node {
let mut builder = NodeBuilder::new("participant").attr("jid", self.jid);
if let Some(pn) = self.phone_number {
builder = builder.attr("phone_number", pn);
}
if let Some(lid) = self.lid {
builder = builder.attr("lid", lid);
}
if let Some(username) = self.username {
builder = builder.attr("username", username);
}
if self.participant_type != ParticipantType::Member {
builder = builder.attr("type", self.participant_type.as_str());
}
if let Some(details) = self.details {
builder = details.apply_to_builder(builder);
}
builder.build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
if node.tag != "participant" {
return Err(anyhow!("expected <participant>, got <{}>", node.tag));
}
let mut attrs = node.attrs();
let jid = attrs
.optional_jid("jid")
.ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?;
let phone_number = attrs.optional_jid("phone_number");
let lid = attrs.optional_jid("lid");
let username = attrs
.optional_string("username")
.or_else(|| attrs.optional_string("participant_username"))
.map(|value| CompactString::from(value.as_ref()));
let participant_type = attrs
.optional_string("type")
.and_then(|s| ParticipantType::try_from(s.as_ref()).ok())
.unwrap_or(ParticipantType::Member);
Ok(Self {
jid,
phone_number,
lid,
username,
participant_type,
details: GroupParticipantDetails::from_node(node)?,
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GroupEphemeralSettings {
pub expiration: Option<u32>,
pub trigger: Option<u32>,
}
impl ProtocolNode for GroupEphemeralSettings {
fn tag(&self) -> &'static str {
"ephemeral"
}
fn into_node(self) -> Node {
let mut builder = NodeBuilder::new("ephemeral");
if let Some(expiration) = self.expiration {
builder = builder.attr("expiration", expiration);
}
if let Some(trigger) = self.trigger {
builder = builder.attr("trigger", trigger);
}
builder.build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
if node.tag != "ephemeral" {
return Err(anyhow!("expected <ephemeral>, got <{}>", node.tag));
}
Ok(Self {
expiration: optional_bounded_u32_attr(
node,
"expiration",
GROUP_EPHEMERAL_EXPIRATION_MAX,
)?,
trigger: optional_bounded_u32_attr(node, "trigger", GROUP_SETTING_TRIGGER_MAX)?,
})
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GroupInfoResponse {
pub id: Jid,
pub subject: GroupSubject,
pub notify: Option<String>,
pub addressing_mode: AddressingMode,
pub participants: Vec<GroupParticipantResponse>,
pub creator: Option<Jid>,
pub creator_pn: Option<Jid>,
pub creator_username: Option<String>,
pub creator_country_code: Option<String>,
pub creation_time: Option<u64>,
pub participant_version_id: Option<String>,
pub admin_version_id: Option<String>,
pub open_thread_id: Option<String>,
pub has_missing_participant_identification: bool,
pub subject_time: Option<u64>,
pub subject_owner: Option<Jid>,
pub subject_owner_pn: Option<Jid>,
pub subject_owner_username: Option<String>,
pub description: Option<String>,
pub description_id: Option<String>,
pub description_owner: Option<Jid>,
pub description_owner_pn: Option<Jid>,
pub description_owner_username: Option<String>,
pub description_time: Option<u64>,
pub is_locked: bool,
pub is_announcement: bool,
pub ephemeral: Option<GroupEphemeralSettings>,
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_membership_approval_required: 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,
pub no_frequently_forwarded: bool,
pub member_share_history_mode: Option<MemberShareHistoryMode>,
pub growth_locked: Option<GrowthLockInfo>,
pub is_suspended: bool,
pub suspension_can_auto_file: bool,
pub appeal_status: Option<GroupAppealStatus>,
pub appeal_update_time: Option<u64>,
pub is_support_group: bool,
pub allow_admin_reports: bool,
pub is_hidden_group: bool,
pub is_incognito: bool,
pub has_group_history: bool,
pub is_auto_add_disabled: bool,
pub has_capi: bool,
pub evolution_version: Option<u32>,
pub has_group_safety_check: bool,
pub participant_label_enabled: bool,
pub is_limit_sharing_enabled: bool,
pub limit_sharing_trigger: Option<u32>,
}
impl ProtocolNode for GroupInfoResponse {
fn tag(&self) -> &'static str {
"group"
}
fn into_node(self) -> Node {
let mut children: Vec<Node> = self
.participants
.into_iter()
.map(|p| p.into_node())
.collect();
if self.has_missing_participant_identification {
children.push(NodeBuilder::new("missing_participant_identification").build());
}
if self.is_locked {
children.push(NodeBuilder::new("locked").build());
}
if self.is_announcement {
children.push(NodeBuilder::new("announcement").build());
}
if let Some(ephemeral) = self.ephemeral {
children.push(ephemeral.into_node());
}
if self.membership_approval {
children.push(
NodeBuilder::new("membership_approval_mode")
.children(vec![
NodeBuilder::new("group_join").attr("state", "on").build(),
])
.build(),
);
}
if let Some(ref add_mode) = self.member_add_mode {
children.push(
NodeBuilder::new("member_add_mode")
.string_content(add_mode.as_str())
.build(),
);
}
if let Some(ref link_mode) = self.member_link_mode {
children.push(
NodeBuilder::new("member_link_mode")
.string_content(link_mode.as_str())
.build(),
);
}
if self.description.is_some()
|| self.description_id.is_some()
|| self.description_owner.is_some()
|| self.description_owner_pn.is_some()
|| self.description_owner_username.is_some()
|| self.description_time.is_some()
{
let mut desc_builder = NodeBuilder::new("description");
if let Some(ref desc_id) = self.description_id {
desc_builder = desc_builder.attr("id", desc_id.as_str());
}
if let Some(ref owner) = self.description_owner {
desc_builder = desc_builder.attr("participant", owner);
}
if let Some(ref owner_pn) = self.description_owner_pn {
desc_builder = desc_builder.attr("participant_pn", owner_pn);
}
if let Some(ref username) = self.description_owner_username {
desc_builder = desc_builder.attr("participant_username", username);
}
if let Some(t) = self.description_time {
desc_builder = desc_builder.attr("t", t);
}
if let Some(ref desc) = self.description {
desc_builder = desc_builder.children([NodeBuilder::new("body")
.string_content(desc.as_str())
.build()]);
}
children.push(desc_builder.build());
}
if self.is_parent_group {
let mut parent = NodeBuilder::new("parent");
if self.parent_membership_approval_required {
parent = parent.attr("default_membership_approval_mode", "request_required");
}
children.push(parent.build());
}
if let Some(ref parent_jid) = self.parent_group_jid {
children.push(
NodeBuilder::new("linked_parent")
.attr("jid", parent_jid)
.build(),
);
}
if self.is_default_sub_group {
children.push(NodeBuilder::new("default_sub_group").build());
}
if self.is_general_chat {
children.push(NodeBuilder::new("general_chat").build());
}
if self.allow_non_admin_sub_group_creation {
children.push(NodeBuilder::new("allow_non_admin_sub_group_creation").build());
}
if self.no_frequently_forwarded {
children.push(NodeBuilder::new("no_frequently_forwarded").build());
}
if let Some(ref mode) = self.member_share_history_mode {
children.push(
NodeBuilder::new("member_share_group_history_mode")
.string_content(mode.as_str())
.build(),
);
}
if let Some(ref gl) = self.growth_locked {
children.push(
NodeBuilder::new("growth_locked")
.attr("type", &gl.lock_type)
.attr("expiration", gl.expiration)
.build(),
);
}
if self.is_support_group {
children.push(NodeBuilder::new("support").build());
}
if self.is_suspended {
let mut suspended = NodeBuilder::new("suspended");
if self.suspension_can_auto_file {
suspended = suspended.attr("can_auto_file", "true");
}
children.push(suspended.build());
}
if let Some(status) = self.appeal_status {
children.push(
NodeBuilder::new("appeal_status")
.attr("type", status.as_str())
.build(),
);
}
if let Some(value) = self.appeal_update_time {
children.push(
NodeBuilder::new("appeal_update_time")
.attr("value", value)
.build(),
);
}
if self.allow_admin_reports {
children.push(NodeBuilder::new("allow_admin_reports").build());
}
if self.is_hidden_group {
children.push(NodeBuilder::new("hidden_group").build());
}
if self.is_incognito {
children.push(NodeBuilder::new("incognito").build());
}
if self.has_group_history {
children.push(NodeBuilder::new("group_history").build());
}
if self.is_auto_add_disabled {
children.push(NodeBuilder::new("auto_add_disabled").build());
}
if self.has_capi {
children.push(NodeBuilder::new("capi").build());
}
if let Some(value) = self.evolution_version {
children.push(
NodeBuilder::new("evolution_version")
.attr("value", value)
.build(),
);
}
if self.has_group_safety_check {
children.push(NodeBuilder::new("group_safety_check").build());
}
if self.participant_label_enabled {
children.push(NodeBuilder::new("participant_label_enabled").build());
}
if self.is_limit_sharing_enabled {
let mut limit_sharing = NodeBuilder::new("limit_sharing_enabled");
if let Some(trigger) = self.limit_sharing_trigger {
limit_sharing = limit_sharing.attr("trigger", trigger);
}
children.push(limit_sharing.build());
}
let mut builder = NodeBuilder::new("group")
.attr("id", self.id)
.attr("subject", self.subject.as_str())
.attr("addressing_mode", self.addressing_mode.as_str());
if let Some(notify) = self.notify {
builder = builder.attr("notify", notify);
}
if let Some(creator) = self.creator {
builder = builder.attr("creator", creator);
}
if let Some(creator_pn) = self.creator_pn {
builder = builder.attr("creator_pn", creator_pn);
}
if let Some(creator_username) = self.creator_username {
builder = builder.attr("creator_username", creator_username);
}
if let Some(creator_country_code) = self.creator_country_code {
builder = builder.attr("creator_country_code", creator_country_code);
}
if let Some(creation_time) = self.creation_time {
builder = builder.attr("creation", creation_time);
}
if let Some(participant_version_id) = self.participant_version_id {
builder = builder.attr("p_v_id", participant_version_id);
}
if let Some(admin_version_id) = self.admin_version_id {
builder = builder.attr("a_v_id", admin_version_id);
}
if let Some(open_thread_id) = self.open_thread_id {
builder = builder.attr("open_thread_id", open_thread_id);
}
if let Some(subject_time) = self.subject_time {
builder = builder.attr("s_t", subject_time);
}
if let Some(subject_owner) = self.subject_owner {
builder = builder.attr("s_o", subject_owner);
}
if let Some(subject_owner_pn) = self.subject_owner_pn {
builder = builder.attr("s_o_pn", subject_owner_pn);
}
if let Some(subject_owner_username) = self.subject_owner_username {
builder = builder.attr("s_o_username", subject_owner_username);
}
if let Some(size) = self.size {
builder = builder.attr("size", size);
}
builder.children(children).build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
use wacore_binary::NodeContentRef;
if node.tag != "group" && node.tag != "community" {
return Err(anyhow!(
"expected <group> or <community>, got <{}>",
node.tag
));
}
let mut attrs = node.attrs();
let id_str = attrs
.optional_string("id")
.ok_or_else(|| anyhow!("missing required attribute id"))?;
let id = if id_str.contains('@') {
id_str.parse()?
} else {
Jid::group(id_str.as_ref())
};
let subject = GroupSubject::new_unchecked(
attrs
.optional_string("subject")
.as_deref()
.unwrap_or_default(),
);
let notify = attrs
.optional_string("notify")
.map(|value| value.into_owned());
let addressing_mode = AddressingMode::try_from(
attrs
.optional_string("addressing_mode")
.as_deref()
.unwrap_or("pn"),
)?;
let creator = attrs.optional_jid("creator");
let creator_pn = attrs.optional_jid("creator_pn");
let creator_username = attrs
.optional_string("creator_username")
.map(|value| value.into_owned());
let creator_country_code = attrs
.optional_string("creator_country_code")
.map(|value| value.into_owned());
let creation_time = attrs.optional_u64("creation");
let participant_version_id = attrs
.optional_string("p_v_id")
.map(|value| value.into_owned());
let admin_version_id = attrs
.optional_string("a_v_id")
.map(|value| value.into_owned());
let open_thread_id = attrs
.optional_string("open_thread_id")
.map(|value| value.into_owned());
let has_missing_participant_identification = node
.get_optional_child_by_tag(&["missing_participant_identification"])
.is_some();
let subject_time = attrs.optional_u64("s_t");
let subject_owner = attrs.optional_jid("s_o");
let subject_owner_pn = attrs.optional_jid("s_o_pn");
let subject_owner_username = attrs
.optional_string("s_o_username")
.map(|value| value.into_owned());
let size = optional_bounded_u32_attr(node, "size", GROUP_INFO_PARTICIPANT_LIMIT)?;
let participants = collect_children::<GroupParticipantResponse>(node, "participant")?;
if participants.len() > GROUP_INFO_PARTICIPANT_LIMIT as usize {
return Err(anyhow!(
"group-info participant count exceeds {GROUP_INFO_PARTICIPANT_LIMIT}: {}",
participants.len()
));
}
let is_locked = node.get_optional_child_by_tag(&["locked"]).is_some();
let is_announcement = node.get_optional_child_by_tag(&["announcement"]).is_some();
let ephemeral = node
.get_optional_child_by_tag(&["ephemeral"])
.map(GroupEphemeralSettings::try_from_node_ref)
.transpose()?;
let membership_approval = node
.get_optional_child_by_tag(&["membership_approval_mode", "group_join"])
.and_then(|n| n.attrs().optional_string("state"))
.is_some_and(|s| s == "on");
let member_add_mode = node
.get_optional_child_by_tag(&["member_add_mode"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::String(s)) => MemberAddMode::try_from(s.as_ref()).ok(),
_ => None,
});
let member_link_mode = node
.get_optional_child_by_tag(&["member_link_mode"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::String(s)) => MemberLinkMode::try_from(s.as_ref()).ok(),
_ => None,
});
let description_node = node.get_optional_child_by_tag(&["description"]);
let description = description_node
.and_then(|n| n.get_optional_child("body"))
.and_then(|body| body.content_as_string())
.map(|s| s.to_string());
let description_id = description_node
.and_then(|n| n.attrs().optional_string("id"))
.map(|s| s.to_string());
let description_owner =
description_node.and_then(|n| n.attrs().optional_jid("participant"));
let description_owner_pn =
description_node.and_then(|n| n.attrs().optional_jid("participant_pn"));
let description_owner_username = description_node
.and_then(|n| n.attrs().optional_string("participant_username"))
.map(|value| value.into_owned());
let description_time = description_node
.and_then(|n| n.attrs().optional_string("t"))
.and_then(|s| s.parse::<u64>().ok());
let parent_node = node.get_optional_child_by_tag(&["parent"]);
let is_parent_group = parent_node.is_some();
let parent_membership_approval_required = parent_node
.and_then(|parent| {
parent
.attrs()
.optional_string("default_membership_approval_mode")
})
.is_some_and(|value| value == "request_required");
let parent_group_jid = node
.get_optional_child_by_tag(&["linked_parent"])
.and_then(|n| n.attrs().optional_jid("jid"));
let is_default_sub_group = node
.get_optional_child_by_tag(&["default_sub_group"])
.is_some();
let is_general_chat = node.get_optional_child_by_tag(&["general_chat"]).is_some();
let allow_non_admin_sub_group_creation = node
.get_optional_child_by_tag(&["allow_non_admin_sub_group_creation"])
.is_some();
let no_frequently_forwarded = node
.get_optional_child_by_tag(&["no_frequently_forwarded"])
.is_some();
let member_share_history_mode = node
.get_optional_child_by_tag(&["member_share_group_history_mode"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::String(s)) => {
MemberShareHistoryMode::try_from(s.as_ref()).ok()
}
_ => None,
});
let growth_locked = node.get_optional_child_by_tag(&["growth_locked"]).map(|n| {
let mut attrs = n.attrs();
GrowthLockInfo {
lock_type: attrs
.optional_string("type")
.unwrap_or_default()
.to_string(),
expiration: attrs
.optional_string("expiration")
.and_then(|s| s.parse().ok())
.unwrap_or(0),
}
});
let is_support_group = node.get_optional_child_by_tag(&["support"]).is_some();
let suspended_node = node.get_optional_child_by_tag(&["suspended"]);
let is_suspended = suspended_node.is_some();
let suspension_can_auto_file = match suspended_node {
None => false,
Some(suspended) => {
let mut attrs = suspended.attrs();
let can_auto_file = attrs.optional_bool("can_auto_file");
attrs.finish()?;
can_auto_file
}
};
let appeal_status =
node.get_optional_child_by_tag(&["appeal_status"])
.map(|appeal| {
let value = appeal.attrs().optional_string("type").ok_or_else(|| {
anyhow!("appeal_status missing required 'type' attribute")
})?;
GroupAppealStatus::try_from(value.as_ref())
.map_err(|_| anyhow!("invalid appeal status '{value}'"))
})
.transpose()?;
let appeal_update_time = node
.get_optional_child_by_tag(&["appeal_update_time"])
.map(|appeal| {
optional_u64_attr(appeal, "value")?
.ok_or_else(|| anyhow!("appeal_update_time missing required 'value' attribute"))
})
.transpose()?;
let allow_admin_reports = node
.get_optional_child_by_tag(&["allow_admin_reports"])
.is_some();
let is_hidden_group = node.get_optional_child_by_tag(&["hidden_group"]).is_some();
let is_incognito = node.get_optional_child_by_tag(&["incognito"]).is_some();
let has_group_history = node.get_optional_child_by_tag(&["group_history"]).is_some();
let is_auto_add_disabled = node
.get_optional_child_by_tag(&["auto_add_disabled"])
.is_some();
let has_capi = node.get_optional_child_by_tag(&["capi"]).is_some();
let evolution_version = node
.get_optional_child_by_tag(&["evolution_version"])
.map(|evolution| {
optional_bounded_u32_attr(evolution, "value", GROUP_EVOLUTION_VERSION_MAX)?
.ok_or_else(|| anyhow!("evolution_version missing required 'value' attribute"))
})
.transpose()?;
let has_group_safety_check = node
.get_optional_child_by_tag(&["group_safety_check"])
.is_some();
let participant_label_enabled = node
.get_optional_child_by_tag(&["participant_label_enabled"])
.is_some();
let limit_sharing_node = node.get_optional_child_by_tag(&["limit_sharing_enabled"]);
let is_limit_sharing_enabled = limit_sharing_node.is_some();
let limit_sharing_trigger = limit_sharing_node
.map(|limit| optional_bounded_u32_attr(limit, "trigger", GROUP_SETTING_TRIGGER_MAX))
.transpose()?
.flatten();
Ok(Self {
id,
subject,
notify,
addressing_mode,
participants,
creator,
creator_pn,
creator_username,
creator_country_code,
creation_time,
participant_version_id,
admin_version_id,
open_thread_id,
has_missing_participant_identification,
subject_time,
subject_owner,
subject_owner_pn,
subject_owner_username,
description,
description_id,
description_owner,
description_owner_pn,
description_owner_username,
description_time,
is_locked,
is_announcement,
ephemeral,
membership_approval,
member_add_mode,
member_link_mode,
size,
is_parent_group,
parent_membership_approval_required,
parent_group_jid,
is_default_sub_group,
is_general_chat,
allow_non_admin_sub_group_creation,
no_frequently_forwarded,
member_share_history_mode,
growth_locked,
is_suspended,
suspension_can_auto_file,
appeal_status,
appeal_update_time,
is_support_group,
allow_admin_reports,
is_hidden_group,
is_incognito,
has_group_history,
is_auto_add_disabled,
has_capi,
evolution_version,
has_group_safety_check,
participant_label_enabled,
is_limit_sharing_enabled,
limit_sharing_trigger,
})
}
}
#[derive(Debug, Clone)]
pub struct GroupParticipatingRequest {
pub include_participants: bool,
pub include_description: bool,
}
impl GroupParticipatingRequest {
pub fn new() -> Self {
Self {
include_participants: true,
include_description: true,
}
}
}
impl Default for GroupParticipatingRequest {
fn default() -> Self {
Self::new()
}
}
impl ProtocolNode for GroupParticipatingRequest {
fn tag(&self) -> &'static str {
"participating"
}
fn into_node(self) -> Node {
let mut children = Vec::new();
if self.include_participants {
children.push(NodeBuilder::new("participants").build());
}
if self.include_description {
children.push(NodeBuilder::new("description").build());
}
NodeBuilder::new("participating").children(children).build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
if node.tag != "participating" {
return Err(anyhow!("expected <participating>, got <{}>", node.tag));
}
Ok(Self {
include_participants: node.get_optional_child("participants").is_some(),
include_description: node.get_optional_child("description").is_some(),
})
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct GroupParticipatingResponse {
pub groups: Vec<GroupInfoResponse>,
}
impl ProtocolNode for GroupParticipatingResponse {
fn tag(&self) -> &'static str {
"groups"
}
fn into_node(self) -> Node {
let children: Vec<Node> = self.groups.into_iter().map(|g| g.into_node()).collect();
NodeBuilder::new("groups").children(children).build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
let child_tag = match node.tag.as_ref() {
"groups" => "group",
"communities" => "community",
_ => {
return Err(anyhow!(
"expected <groups> or <communities>, got <{}>",
node.tag
));
}
};
let groups = collect_children::<GroupInfoResponse>(node, child_tag)?;
Ok(Self { groups })
}
}
#[derive(Debug, Clone)]
pub enum GroupInfoOutcome {
Full(Box<GroupInfoResponse>),
NotModified,
}
#[derive(Debug, Clone)]
pub struct GroupQueryIq {
pub group_jid: Jid,
pub phash: Option<String>,
}
impl GroupQueryIq {
pub fn new(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
phash: None,
}
}
pub fn with_phash(group_jid: &Jid, phash: Option<String>) -> Self {
Self {
group_jid: group_jid.clone(),
phash,
}
}
}
impl IqSpec for GroupQueryIq {
type Response = GroupInfoOutcome;
fn build_iq(&self) -> InfoQuery<'static> {
let mut query = GroupQueryRequest::default().into_node();
if let Some(ref phash) = self.phash {
query.attrs.insert("phash", phash.clone());
}
InfoQuery::get_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![query])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
match response
.get_optional_child("group")
.or_else(|| response.get_optional_child("community"))
{
Some(group_node) => Ok(GroupInfoOutcome::Full(Box::new(
GroupInfoResponse::try_from_node_ref(group_node)?,
))),
None => Ok(GroupInfoOutcome::NotModified),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupParticipatingIq;
impl GroupParticipatingIq {
pub fn new() -> Self {
Self
}
}
impl IqSpec for GroupParticipatingIq {
type Response = GroupParticipatingResponse;
fn build_iq(&self) -> InfoQuery<'static> {
build_participating_iq()
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
if has_participating_shape(response, "groups", "group") {
parse_participating_response(response, "groups", "group")
} else {
parse_community_participating_response(response)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CommunityParticipatingIq;
impl CommunityParticipatingIq {
pub fn new() -> Self {
Self
}
}
impl IqSpec for CommunityParticipatingIq {
type Response = GroupParticipatingResponse;
fn build_iq(&self) -> InfoQuery<'static> {
build_participating_iq()
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
if has_participating_shape(response, "communities", "community") {
return parse_community_participating_response(response);
}
let mut result = parse_participating_response(response, "groups", "group")?;
result.groups.retain(|group| group.is_parent_group);
Ok(result)
}
}
fn parse_community_participating_response(
response: &NodeRef<'_>,
) -> Result<GroupParticipatingResponse> {
let mut result = parse_participating_response(response, "communities", "community")?;
for community in &mut result.groups {
community.is_parent_group = true;
}
Ok(result)
}
fn build_participating_iq() -> InfoQuery<'static> {
InfoQuery::get(
GROUP_IQ_NAMESPACE,
Jid::new("", Server::Group),
Some(NodeContent::Nodes(vec![
GroupParticipatingRequest::new().into_node(),
])),
)
}
fn has_participating_shape(
response: &NodeRef<'_>,
container_tag: &'static str,
child_tag: &'static str,
) -> bool {
response.tag == container_tag
|| response.get_optional_child(container_tag).is_some()
|| response.get_optional_child(child_tag).is_some()
}
fn parse_participating_response(
response: &NodeRef<'_>,
container_tag: &'static str,
child_tag: &'static str,
) -> Result<GroupParticipatingResponse> {
if response.tag == container_tag {
return GroupParticipatingResponse::try_from_node_ref(response);
}
if let Some(container) = response.get_optional_child(container_tag) {
return GroupParticipatingResponse::try_from_node_ref(container);
}
let groups = collect_children::<GroupInfoResponse>(response, child_tag)?;
if groups.is_empty() {
return Err(anyhow!(
"missing <{container_tag}> or direct <{child_tag}> participating result"
));
}
Ok(GroupParticipatingResponse { groups })
}
#[derive(Debug, Clone)]
pub struct GroupCreateIq {
pub options: GroupCreateOptions,
}
impl GroupCreateIq {
pub fn new(options: GroupCreateOptions) -> Self {
Self { options }
}
}
impl IqSpec for GroupCreateIq {
type Response = GroupInfoResponse;
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set(
GROUP_IQ_NAMESPACE,
Jid::new("", Server::Group),
Some(NodeContent::Nodes(vec![build_create_group_node(
&self.options,
)])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let group_node = response
.get_optional_child("group")
.or_else(|| response.get_optional_child("community"))
.ok_or_else(|| anyhow!("missing group or community create result"))?;
let mut info = GroupInfoResponse::try_from_node_ref(group_node)?;
let is_linked_subgroup =
info.parent_group_jid.is_some() || self.options.linked_parent.is_some();
if self.options.is_parent && !is_linked_subgroup {
info.is_parent_group = true;
info.parent_membership_approval_required |= self.options.closed;
info.allow_non_admin_sub_group_creation |=
self.options.allow_non_admin_sub_group_creation;
}
Ok(info)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddRequestInfo {
pub code: String,
pub expiration: u64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ParticipantChangeResponse {
pub jid: Jid,
pub status: Option<String>,
pub error: Option<String>,
pub phone_number: Option<Jid>,
pub username: Option<String>,
pub add_request: Option<AddRequestInfo>,
}
impl ParticipantChangeResponse {
pub fn is_ok(&self) -> bool {
self.error.is_none()
}
}
impl ProtocolNode for ParticipantChangeResponse {
fn tag(&self) -> &'static str {
"participant"
}
fn into_node(self) -> ::wacore_binary::node::Node {
let mut builder =
::wacore_binary::builder::NodeBuilder::new("participant").attr("jid", &self.jid);
if let Some(s) = self.status {
builder = builder.attr("type", s);
}
if let Some(e) = self.error {
builder = builder.attr("error", e);
}
if let Some(ref pn) = self.phone_number {
builder = builder.attr("phone_number", pn);
}
if let Some(u) = self.username {
builder = builder.attr("username", u);
}
if let Some(ar) = self.add_request {
builder = builder.children([::wacore_binary::builder::NodeBuilder::new("add_request")
.attr("code", ar.code)
.attr("expiration", ar.expiration)
.build()]);
}
builder.build()
}
fn try_from_node_ref(node: &::wacore_binary::node::NodeRef<'_>) -> ::anyhow::Result<Self> {
if node.tag != "participant" {
return Err(::anyhow::anyhow!(
"expected <participant>, got <{}>",
node.tag
));
}
let mut attrs = node.attrs();
let jid = attrs
.optional_jid("jid")
.ok_or_else(|| ::anyhow::anyhow!("participant missing required 'jid' attribute"))?;
let status = attrs.optional_string("type").map(|c| c.into_owned());
let error = attrs.optional_string("error").map(|c| c.into_owned());
let phone_number = attrs.optional_jid("phone_number");
let username = attrs.optional_string("username").map(|c| c.into_owned());
let add_request = node
.get_optional_child("add_request")
.map(|n| -> ::anyhow::Result<AddRequestInfo> {
let mut a = n.attrs();
let code = a
.optional_string("code")
.ok_or_else(|| {
::anyhow::anyhow!("<add_request> missing required 'code' attribute")
})?
.into_owned();
let expiration = a
.optional_string("expiration")
.ok_or_else(|| {
::anyhow::anyhow!("<add_request> missing required 'expiration' attribute")
})?
.parse::<u64>()
.map_err(|e| {
::anyhow::anyhow!("<add_request> 'expiration' is not a u64: {e}")
})?;
Ok(AddRequestInfo { code, expiration })
})
.transpose()?;
Ok(Self {
jid,
status,
error,
phone_number,
username,
add_request,
})
}
}
#[derive(Debug, Clone)]
pub struct SetGroupSubjectIq {
pub group_jid: Jid,
pub subject: GroupSubject,
}
impl SetGroupSubjectIq {
pub fn new(group_jid: &Jid, subject: GroupSubject) -> Self {
Self {
group_jid: group_jid.clone(),
subject,
}
}
}
impl IqSpec for SetGroupSubjectIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("subject")
.string_content(self.subject.as_str())
.build(),
])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SetGroupDescriptionIq {
pub group_jid: Jid,
pub description: Option<GroupDescription>,
pub id: String,
pub prev: Option<String>,
}
impl SetGroupDescriptionIq {
pub fn new(group_jid: &Jid, description: Option<GroupDescription>, prev: Option<&str>) -> Self {
let id = generate_description_id();
Self {
group_jid: group_jid.clone(),
description,
id,
prev: prev.map(str::to_string),
}
}
}
impl IqSpec for SetGroupDescriptionIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
let desc_node = if let Some(ref desc) = self.description {
let mut builder = NodeBuilder::new("description").attr("id", &self.id);
if let Some(ref prev) = self.prev {
builder = builder.attr("prev", prev);
}
builder
.children([NodeBuilder::new("body")
.string_content(desc.as_str())
.build()])
.build()
} else {
let mut builder = NodeBuilder::new("description")
.attr("id", &self.id)
.attr("delete", "true");
if let Some(ref prev) = self.prev {
builder = builder.attr("prev", prev);
}
builder.build()
};
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![desc_node])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct LeaveGroupIq {
pub group_jid: Jid,
}
impl LeaveGroupIq {
pub fn new(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
}
}
}
impl IqSpec for LeaveGroupIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
let group_node = NodeBuilder::new("group")
.attr("id", &self.group_jid)
.build();
let leave_node = NodeBuilder::new("leave").children([group_node]).build();
InfoQuery::set(
GROUP_IQ_NAMESPACE,
Jid::new("", Server::Group),
Some(NodeContent::Nodes(vec![leave_node])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
fn build_participant_action_iq(
group_jid: &Jid,
action: &'static str,
participants: &[Jid],
include_linked_groups: bool,
) -> InfoQuery<'static> {
let children: Vec<Node> = participants
.iter()
.map(|jid| NodeBuilder::new("participant").attr("jid", jid).build())
.collect();
let mut action_node = NodeBuilder::new(action);
if include_linked_groups {
action_node = action_node.attr("linked_groups", "true");
}
let action_node = action_node.children(children).build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
group_jid,
Some(NodeContent::Nodes(vec![action_node])),
)
}
macro_rules! define_group_participant_iq {
(
$(#[$meta:meta])*
$name:ident, action = $action:literal, response = Vec<ParticipantChangeResponse>
) => {
$(#[$meta])*
#[derive(Debug, Clone)]
pub struct $name {
pub group_jid: Jid,
pub participants: Vec<Jid>,
}
impl $name {
pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants.to_vec(),
}
}
}
impl IqSpec for $name {
type Response = Vec<ParticipantChangeResponse>;
fn build_iq(&self) -> InfoQuery<'static> {
build_participant_action_iq(&self.group_jid, $action, &self.participants, false)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let action_node = required_child(response, $action)?;
collect_children::<ParticipantChangeResponse>(action_node, "participant")
}
}
};
(
$(#[$meta:meta])*
$name:ident, action = $action:literal, response = ()
) => {
$(#[$meta])*
#[derive(Debug, Clone)]
pub struct $name {
pub group_jid: Jid,
pub participants: Vec<Jid>,
}
impl $name {
pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants.to_vec(),
}
}
}
impl IqSpec for $name {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
build_participant_action_iq(&self.group_jid, $action, &self.participants, false)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
};
}
#[derive(Debug, Clone)]
pub struct AddParticipantsIq {
pub group_jid: Jid,
pub participants: Vec<GroupParticipantOptions>,
}
impl AddParticipantsIq {
pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants
.iter()
.map(|jid| GroupParticipantOptions::new(jid.clone()))
.collect(),
}
}
pub fn with_options(group_jid: &Jid, participants: Vec<GroupParticipantOptions>) -> Self {
Self {
group_jid: group_jid.clone(),
participants,
}
}
}
impl IqSpec for AddParticipantsIq {
type Response = Vec<ParticipantChangeResponse>;
fn build_iq(&self) -> InfoQuery<'static> {
let children: Vec<Node> = self
.participants
.iter()
.map(|p| {
let mut attrs = vec![("jid", p.jid.to_string())];
if p.jid.is_lid()
&& let Some(pn) = &p.phone_number
{
attrs.push(("phone_number", pn.to_string()));
}
if let Some(privacy_bytes) = &p.privacy {
NodeBuilder::new("participant")
.attrs(attrs)
.children([NodeBuilder::new("privacy")
.string_content(hex::encode(privacy_bytes))
.build()])
.build()
} else {
NodeBuilder::new("participant").attrs(attrs).build()
}
})
.collect();
let action_node = NodeBuilder::new("add").children(children).build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![action_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let action_node = required_child(response, "add")?;
collect_children::<ParticipantChangeResponse>(action_node, "participant")
}
}
define_group_participant_iq!(
RemoveParticipantsIq, action = "remove", response = Vec<ParticipantChangeResponse>
);
#[derive(Debug, Clone)]
pub struct RemoveParticipantsIncludingLinkedGroupsIq {
pub group_jid: Jid,
pub participants: Vec<Jid>,
}
impl RemoveParticipantsIncludingLinkedGroupsIq {
pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants.to_vec(),
}
}
}
impl IqSpec for RemoveParticipantsIncludingLinkedGroupsIq {
type Response = Vec<ParticipantChangeResponse>;
fn build_iq(&self) -> InfoQuery<'static> {
build_participant_action_iq(&self.group_jid, "remove", &self.participants, true)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let action_node = required_child(response, "remove")?;
collect_children::<ParticipantChangeResponse>(action_node, "participant")
}
}
define_group_participant_iq!(
PromoteParticipantsIq, action = "promote", response = Vec<ParticipantChangeResponse>
);
define_group_participant_iq!(
DemoteParticipantsIq, action = "demote", response = Vec<ParticipantChangeResponse>
);
#[derive(Debug, Clone)]
pub struct GetGroupInviteLinkIq {
pub group_jid: Jid,
pub reset: bool,
}
impl GetGroupInviteLinkIq {
pub fn new(group_jid: &Jid, reset: bool) -> Self {
Self {
group_jid: group_jid.clone(),
reset,
}
}
}
impl IqSpec for GetGroupInviteLinkIq {
type Response = String;
fn build_iq(&self) -> InfoQuery<'static> {
let content = Some(NodeContent::Nodes(vec![NodeBuilder::new("invite").build()]));
if self.reset {
InfoQuery::set_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
} else {
InfoQuery::get_ref(GROUP_IQ_NAMESPACE, &self.group_jid, content)
}
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let invite_node = required_child(response, "invite")?;
let code = required_attr(invite_node, "code")?;
Ok(format!("https://chat.whatsapp.com/{code}"))
}
}
#[derive(Debug, Clone)]
pub struct SetGroupLockedIq {
pub group_jid: Jid,
pub locked: bool,
}
impl SetGroupLockedIq {
pub fn lock(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
locked: true,
}
}
pub fn unlock(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
locked: false,
}
}
}
impl IqSpec for SetGroupLockedIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
build_property_toggle_iq(
&self.group_jid,
if self.locked { "locked" } else { "unlocked" },
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SetGroupAnnouncementIq {
pub group_jid: Jid,
pub announce: bool,
}
impl SetGroupAnnouncementIq {
pub fn announce(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
announce: true,
}
}
pub fn unannounce(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
announce: false,
}
}
}
impl IqSpec for SetGroupAnnouncementIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
build_property_toggle_iq(
&self.group_jid,
if self.announce {
"announcement"
} else {
"not_announcement"
},
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SetGroupEphemeralIq {
pub group_jid: Jid,
pub expiration: Option<NonZeroU32>,
pub trigger: Option<u32>,
}
impl SetGroupEphemeralIq {
pub fn enable(group_jid: &Jid, expiration: NonZeroU32) -> Self {
Self {
group_jid: group_jid.clone(),
expiration: Some(expiration),
trigger: None,
}
}
pub fn enable_with_trigger(group_jid: &Jid, expiration: NonZeroU32, trigger: u32) -> Self {
assert!(
trigger <= EPHEMERAL_TRIGGER_MAX,
"ephemeral trigger must be in 0..={EPHEMERAL_TRIGGER_MAX}, got {trigger}"
);
Self {
group_jid: group_jid.clone(),
expiration: Some(expiration),
trigger: Some(trigger),
}
}
pub fn disable(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
expiration: None,
trigger: None,
}
}
}
impl IqSpec for SetGroupEphemeralIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
let node = match self.expiration {
Some(exp) => {
let mut b = NodeBuilder::new("ephemeral").attr("expiration", exp.get());
if let Some(trigger) = self.trigger
&& trigger <= EPHEMERAL_TRIGGER_MAX
{
b = b.attr("trigger", trigger);
}
b.build()
}
None => NodeBuilder::new("not_ephemeral").build(),
};
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![node])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SetGroupMembershipApprovalIq {
pub group_jid: Jid,
pub mode: MembershipApprovalMode,
}
impl SetGroupMembershipApprovalIq {
pub fn new(group_jid: &Jid, mode: MembershipApprovalMode) -> Self {
Self {
group_jid: group_jid.clone(),
mode,
}
}
}
impl IqSpec for SetGroupMembershipApprovalIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
let node = NodeBuilder::new("membership_approval_mode")
.children([NodeBuilder::new("group_join")
.attr("state", self.mode.as_str())
.build()])
.build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![node])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
fn build_property_toggle_iq(group_jid: &Jid, tag: &'static str) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
group_jid,
Some(NodeContent::Nodes(vec![NodeBuilder::new(tag).build()])),
)
}
macro_rules! define_group_property_toggle_iq {
(
$(#[$meta:meta])*
$name:ident, on_tag = $on:literal, off_tag = $off:literal
) => {
$(#[$meta])*
#[derive(Debug, Clone)]
pub struct $name {
pub group_jid: Jid,
pub enabled: bool,
}
impl $name {
pub fn new(group_jid: &Jid, enabled: bool) -> Self {
Self {
group_jid: group_jid.clone(),
enabled,
}
}
}
impl IqSpec for $name {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
build_property_toggle_iq(&self.group_jid, if self.enabled { $on } else { $off })
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
};
}
define_group_property_toggle_iq!(
SetNoFrequentlyForwardedIq,
on_tag = "no_frequently_forwarded",
off_tag = "frequently_forwarded_ok"
);
define_group_property_toggle_iq!(
SetAllowAdminReportsIq,
on_tag = "allow_admin_reports",
off_tag = "not_allow_admin_reports"
);
define_group_property_toggle_iq!(
SetGroupHistoryIq,
on_tag = "group_history",
off_tag = "no_group_history"
);
#[derive(Debug, Clone)]
pub struct LinkedGroupResult {
pub jid: Jid,
pub error: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct LinkSubgroupsResponse {
pub groups: Vec<LinkedGroupResult>,
}
#[derive(Debug, Clone)]
pub struct UnlinkSubgroupsResponse {
pub groups: Vec<LinkedGroupResult>,
}
#[derive(Debug, Clone)]
pub struct LinkSubgroupsIq {
pub parent_jid: Jid,
pub subgroup_jids: Vec<Jid>,
}
impl LinkSubgroupsIq {
pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid]) -> Self {
Self {
parent_jid: parent_jid.clone(),
subgroup_jids: subgroup_jids.to_vec(),
}
}
}
impl IqSpec for LinkSubgroupsIq {
type Response = LinkSubgroupsResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let group_nodes: Vec<Node> = self
.subgroup_jids
.iter()
.map(|jid| NodeBuilder::new("group").attr("jid", jid).build())
.collect();
let link_node = NodeBuilder::new("link")
.attr("link_type", "sub_group")
.children(group_nodes)
.build();
let links_node = NodeBuilder::new("links").children([link_node]).build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![links_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let links_node = required_child(response, "links")?;
let link_node = required_child(links_node, "link")?;
let mut groups = Vec::new();
for child in link_node.get_children_by_tag("group") {
let jid_str = required_attr(child, "jid")?;
let jid: Jid = jid_str.parse()?;
let error = child
.attrs()
.optional_string("error")
.and_then(|s| s.parse::<u32>().ok());
groups.push(LinkedGroupResult { jid, error });
}
Ok(LinkSubgroupsResponse { groups })
}
}
#[derive(Debug, Clone)]
pub struct UnlinkSubgroupsIq {
pub parent_jid: Jid,
pub subgroup_jids: Vec<Jid>,
pub remove_orphan_members: bool,
}
impl UnlinkSubgroupsIq {
pub fn new(parent_jid: &Jid, subgroup_jids: &[Jid], remove_orphan_members: bool) -> Self {
Self {
parent_jid: parent_jid.clone(),
subgroup_jids: subgroup_jids.to_vec(),
remove_orphan_members,
}
}
}
impl IqSpec for UnlinkSubgroupsIq {
type Response = UnlinkSubgroupsResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let group_nodes: Vec<Node> = self
.subgroup_jids
.iter()
.map(|jid| {
let mut builder = NodeBuilder::new("group").attr("jid", jid);
if self.remove_orphan_members {
builder = builder.attr("remove_orphaned_members", "true");
}
builder.build()
})
.collect();
let unlink_node = NodeBuilder::new("unlink")
.attr("unlink_type", "sub_group")
.children(group_nodes)
.build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![unlink_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let unlink_node = required_child(response, "unlink")?;
let mut groups = Vec::new();
for child in unlink_node.get_children_by_tag("group") {
let jid_str = required_attr(child, "jid")?;
let jid: Jid = jid_str.parse()?;
let error = child
.attrs()
.optional_string("error")
.and_then(|s| s.parse::<u32>().ok());
groups.push(LinkedGroupResult { jid, error });
}
Ok(UnlinkSubgroupsResponse { groups })
}
}
#[derive(Debug, Clone)]
pub struct DeleteCommunityIq {
pub parent_jid: Jid,
}
impl DeleteCommunityIq {
pub fn new(parent_jid: &Jid) -> Self {
Self {
parent_jid: parent_jid.clone(),
}
}
}
impl IqSpec for DeleteCommunityIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("delete_parent").build(),
])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct QueryLinkedGroupIq {
pub parent_jid: Jid,
pub subgroup_jid: Jid,
}
impl QueryLinkedGroupIq {
pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self {
Self {
parent_jid: parent_jid.clone(),
subgroup_jid: subgroup_jid.clone(),
}
}
}
impl IqSpec for QueryLinkedGroupIq {
type Response = GroupInfoResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let query_node = NodeBuilder::new("query_linked")
.attr("type", "sub_group")
.attr("jid", &self.subgroup_jid)
.build();
InfoQuery::get_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![query_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let linked_node = required_child(response, "linked_group")?;
let group_node = required_child(linked_node, "group")?;
GroupInfoResponse::try_from_node_ref(group_node)
}
}
#[derive(Debug, Clone)]
pub struct JoinLinkedGroupIq {
pub parent_jid: Jid,
pub subgroup_jid: Jid,
}
impl JoinLinkedGroupIq {
pub fn new(parent_jid: &Jid, subgroup_jid: &Jid) -> Self {
Self {
parent_jid: parent_jid.clone(),
subgroup_jid: subgroup_jid.clone(),
}
}
}
impl IqSpec for JoinLinkedGroupIq {
type Response = GroupInfoResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let node = NodeBuilder::new("join_linked_group")
.attr("jid", &self.subgroup_jid)
.build();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let linked_node = required_child(response, "linked_group")?;
let group_node = required_child(linked_node, "group")?;
GroupInfoResponse::try_from_node_ref(group_node)
}
}
#[derive(Debug, Clone)]
pub struct GetLinkedGroupsParticipantsIq {
pub parent_jid: Jid,
}
impl GetLinkedGroupsParticipantsIq {
pub fn new(parent_jid: &Jid) -> Self {
Self {
parent_jid: parent_jid.clone(),
}
}
}
impl IqSpec for GetLinkedGroupsParticipantsIq {
type Response = Vec<GroupParticipantResponse>;
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::get_ref(
GROUP_IQ_NAMESPACE,
&self.parent_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("linked_groups_participants").build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let container = required_child(response, "linked_groups_participants")?;
let direct = collect_children::<GroupParticipantResponse>(container, "participant")?;
if !direct.is_empty() {
return Ok(direct);
}
let mut all = Vec::new();
for group_node in container.get_children_by_tag("group") {
let participants =
collect_children::<GroupParticipantResponse>(group_node, "participant")?;
all.extend(participants);
}
Ok(all)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum JoinGroupResult {
Joined(Jid),
PendingApproval(Jid),
}
impl JoinGroupResult {
pub fn group_jid(&self) -> &Jid {
match self {
JoinGroupResult::Joined(jid) | JoinGroupResult::PendingApproval(jid) => jid,
}
}
}
fn parse_group_id(id_str: &str) -> Result<Jid> {
if id_str.contains('@') {
id_str.parse().map_err(Into::into)
} else {
Ok(Jid::group(id_str))
}
}
fn parse_join_group_response(response: &NodeRef<'_>) -> Result<JoinGroupResult> {
if let Some(group_node) = response
.get_optional_child("group")
.or_else(|| response.get_optional_child("community"))
{
let jid_str = required_attr(group_node, "jid")?;
let jid: Jid = jid_str
.parse()
.map_err(|e| anyhow!("invalid group jid: {e}"))?;
return Ok(JoinGroupResult::Joined(jid));
}
if let Some(approval_node) = response.get_optional_child("membership_approval_request") {
let jid_str = required_attr(approval_node, "jid")?;
let jid: Jid = jid_str
.parse()
.map_err(|e| anyhow!("invalid group jid: {e}"))?;
return Ok(JoinGroupResult::PendingApproval(jid));
}
Err(anyhow!(
"expected <group>, <community>, or <membership_approval_request> in join response"
))
}
#[derive(Debug, Clone)]
pub struct AcceptGroupInviteIq {
pub code: String,
}
impl AcceptGroupInviteIq {
pub fn new(code: impl Into<String>) -> Self {
Self { code: code.into() }
}
}
impl IqSpec for AcceptGroupInviteIq {
type Response = JoinGroupResult;
fn build_iq(&self) -> InfoQuery<'static> {
let to = Jid::new("", Server::Group);
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&to,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("invite").attr("code", &self.code).build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
parse_join_group_response(response)
}
}
pub struct AcceptGroupInviteV4Iq {
pub group_jid: Jid,
pub code: String,
pub expiration: i64,
pub admin_jid: Jid,
}
impl AcceptGroupInviteV4Iq {
pub fn new(group_jid: &Jid, code: &str, expiration: i64, admin_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
code: code.to_string(),
expiration,
admin_jid: admin_jid.clone(),
}
}
}
impl IqSpec for AcceptGroupInviteV4Iq {
type Response = JoinGroupResult;
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("accept")
.attr("code", &self.code)
.attr("expiration", self.expiration)
.attr("admin", &self.admin_jid)
.build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
parse_join_group_response(response)
}
}
#[derive(Debug, Clone)]
pub struct GetGroupInviteInfoIq {
pub code: String,
}
impl GetGroupInviteInfoIq {
pub fn new(code: impl Into<String>) -> Self {
Self { code: code.into() }
}
}
impl IqSpec for GetGroupInviteInfoIq {
type Response = GroupInfoResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let to = Jid::new("", Server::Group);
InfoQuery::get_ref(
GROUP_IQ_NAMESPACE,
&to,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("invite").attr("code", &self.code).build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let group_node = response
.get_optional_child("group")
.or_else(|| response.get_optional_child("community"))
.ok_or_else(|| anyhow!("missing group or community invite result"))?;
GroupInfoResponse::try_from_node_ref(group_node)
}
}
#[derive(Debug, Clone)]
pub struct GetMembershipRequestsIq {
pub group_jid: Jid,
}
impl GetMembershipRequestsIq {
pub fn new(jid: &Jid) -> Self {
Self {
group_jid: jid.clone(),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MembershipRequest {
pub jid: Jid,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_time: Option<u64>,
}
impl IqSpec for GetMembershipRequestsIq {
type Response = Vec<MembershipRequest>;
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::get_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("membership_approval_requests").build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let requests_node = response
.get_optional_child("membership_approval_requests")
.ok_or_else(|| anyhow!("missing membership_approval_requests"))?;
let mut requests = Vec::new();
for child in requests_node.get_children_by_tag("membership_approval_request") {
let jid_str = required_attr(child, "jid")?;
let jid: Jid = jid_str
.parse()
.map_err(|e| anyhow!("invalid jid in membership request: {e}"))?;
let request_time = child
.attrs()
.optional_string("request_time")
.and_then(|s| s.parse::<u64>().ok());
requests.push(MembershipRequest { jid, request_time });
}
Ok(requests)
}
}
#[derive(Debug, Clone)]
pub struct MembershipRequestActionIq {
pub group_jid: Jid,
pub participants: Vec<Jid>,
pub approve: bool,
}
impl MembershipRequestActionIq {
pub fn approve(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants.to_vec(),
approve: true,
}
}
pub fn reject(group_jid: &Jid, participants: &[Jid]) -> Self {
Self {
group_jid: group_jid.clone(),
participants: participants.to_vec(),
approve: false,
}
}
}
impl IqSpec for MembershipRequestActionIq {
type Response = Vec<ParticipantChangeResponse>;
fn build_iq(&self) -> InfoQuery<'static> {
let action_tag = if self.approve { "approve" } else { "reject" };
let participant_nodes: Vec<Node> = self
.participants
.iter()
.map(|jid| NodeBuilder::new("participant").attr("jid", jid).build())
.collect();
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("membership_requests_action")
.children(vec![
NodeBuilder::new(action_tag)
.children(participant_nodes)
.build(),
])
.build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let action_node = required_child(response, "membership_requests_action")?;
let action_tag = if self.approve { "approve" } else { "reject" };
let inner = required_child(action_node, action_tag)?;
collect_children::<ParticipantChangeResponse>(inner, "participant")
}
}
#[derive(Debug, Clone)]
pub struct SetMemberAddModeIq {
pub group_jid: Jid,
pub mode: MemberAddMode,
}
impl SetMemberAddModeIq {
pub fn new(jid: &Jid, mode: MemberAddMode) -> Self {
Self {
group_jid: jid.clone(),
mode,
}
}
}
impl IqSpec for SetMemberAddModeIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![
NodeBuilder::new("member_add_mode")
.string_content(self.mode.as_str())
.build(),
])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
define_group_participant_iq!(
CancelMembershipRequestsIq,
action = "cancel_membership_requests",
response = Vec<ParticipantChangeResponse>
);
define_group_participant_iq!(
RevokeRequestCodeIq,
action = "revoke",
response = Vec<ParticipantChangeResponse>
);
#[derive(Debug, Clone)]
pub struct AcknowledgeGroupIq {
pub group_jid: Jid,
}
impl AcknowledgeGroupIq {
pub fn new(group_jid: &Jid) -> Self {
Self {
group_jid: group_jid.clone(),
}
}
}
impl IqSpec for AcknowledgeGroupIq {
type Response = ();
fn build_iq(&self) -> InfoQuery<'static> {
InfoQuery::set_ref(
GROUP_IQ_NAMESPACE,
&self.group_jid,
Some(NodeContent::Nodes(vec![NodeBuilder::new("ack").build()])),
)
}
fn parse_response(&self, _response: &NodeRef<'_>) -> Result<Self::Response> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum BatchGroupInfoResult {
Full(Box<GroupInfoResponse>),
Truncated {
id: Jid,
size: Option<u32>,
},
Forbidden(Jid),
NotFound(Jid),
}
#[derive(Debug, Clone)]
pub struct BatchGetGroupInfoIq {
pub group_jids: Vec<Jid>,
}
impl BatchGetGroupInfoIq {
pub fn new(group_jids: &[Jid]) -> Self {
Self {
group_jids: group_jids.to_vec(),
}
}
}
impl IqSpec for BatchGetGroupInfoIq {
type Response = Vec<BatchGroupInfoResult>;
fn build_iq(&self) -> InfoQuery<'static> {
let children: Vec<Node> = self
.group_jids
.iter()
.map(|jid| NodeBuilder::new("group").attr("jid", jid).build())
.collect();
let query_node = NodeBuilder::new("query").children(children).build();
InfoQuery::get(
GROUP_IQ_NAMESPACE,
Jid::new("", Server::Group),
Some(NodeContent::Nodes(vec![query_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let groups_node = required_child(response, "groups")?;
let mut results = Vec::new();
for group_node in groups_node.get_children_by_tag("group") {
let mut attrs = group_node.attrs();
if let Some(error_code) = attrs.optional_string("error") {
let id_str = required_attr(group_node, "id")?;
let id = parse_group_id(&id_str)?;
match error_code.as_ref() {
"403" => results.push(BatchGroupInfoResult::Forbidden(id)),
_ => results.push(BatchGroupInfoResult::NotFound(id)),
};
continue;
}
let is_truncated = attrs
.optional_string("truncated")
.is_some_and(|s| s == "true");
if is_truncated {
let id_str = required_attr(group_node, "id")?;
let id = parse_group_id(&id_str)?;
let size = attrs.optional_string("size").and_then(|s| s.parse().ok());
results.push(BatchGroupInfoResult::Truncated { id, size });
} else {
let info = GroupInfoResponse::try_from_node_ref(group_node)?;
results.push(BatchGroupInfoResult::Full(Box::new(info)));
}
}
Ok(results)
}
}
#[derive(Debug, Clone)]
pub struct GroupProfilePicture {
pub group_jid: Jid,
pub url: Option<String>,
pub direct_path: Option<String>,
pub photo_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PictureType {
Preview,
Image,
}
#[derive(Debug, Clone)]
pub struct GetGroupProfilePicturesIq {
pub groups: Vec<(Jid, PictureType)>,
}
impl GetGroupProfilePicturesIq {
pub fn new(group_jids: &[Jid]) -> Self {
Self {
groups: group_jids
.iter()
.map(|jid| (jid.clone(), PictureType::Preview))
.collect(),
}
}
pub fn with_type(groups: &[(Jid, PictureType)]) -> Self {
Self {
groups: groups.to_vec(),
}
}
}
impl IqSpec for GetGroupProfilePicturesIq {
type Response = Vec<GroupProfilePicture>;
fn build_iq(&self) -> InfoQuery<'static> {
let children: Vec<Node> = self
.groups
.iter()
.map(|(jid, pic_type)| {
let type_str = match pic_type {
PictureType::Preview => "preview",
PictureType::Image => "image",
};
NodeBuilder::new("picture")
.attr("jid", jid)
.attr("type", type_str)
.build()
})
.collect();
let pictures_node = NodeBuilder::new("pictures").children(children).build();
InfoQuery::get(
GROUP_IQ_NAMESPACE,
Jid::new("", Server::Group),
Some(NodeContent::Nodes(vec![pictures_node])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
let pictures_node = required_child(response, "pictures")?;
let mut results = Vec::new();
for pic_node in pictures_node.get_children_by_tag("picture") {
let mut attrs = pic_node.attrs();
if let Some(jid_str) = attrs.optional_string("jid") {
let jid = parse_group_id(&jid_str)?;
results.push(GroupProfilePicture {
group_jid: jid,
url: attrs.optional_string("url").map(|s| s.to_string()),
direct_path: attrs.optional_string("direct_path").map(|s| s.to_string()),
photo_id: attrs.optional_string("id").map(|s| s.to_string()),
});
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request::InfoQueryType;
#[test]
fn group_query_iq_with_phash_emits_attr() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let iq = GroupQueryIq::with_phash(&jid, Some("2:abc123".to_string())).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected NodeContent::Nodes");
};
let query = &nodes[0];
assert_eq!(query.tag, "query");
assert!(
query
.attrs
.get("request")
.is_some_and(|s| s == "interactive")
);
assert!(query.attrs.get("phash").is_some_and(|s| s == "2:abc123"));
}
#[test]
fn group_query_iq_without_phash_has_no_attr() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let iq = GroupQueryIq::new(&jid).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected NodeContent::Nodes");
};
assert!(nodes[0].attrs.get("phash").is_none());
}
#[test]
fn group_query_parse_full_vs_not_modified() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = GroupQueryIq::new(&jid);
let full = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.build()])
.build();
assert!(matches!(
spec.parse_response(&full.as_node_ref()).unwrap(),
GroupInfoOutcome::Full(_)
));
let nm = NodeBuilder::new("iq").build();
assert!(matches!(
spec.parse_response(&nm.as_node_ref()).unwrap(),
GroupInfoOutcome::NotModified
));
}
#[test]
fn participating_iqs_select_their_own_container() {
let response = NodeBuilder::new("iq")
.children([
NodeBuilder::new("groups")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("subject", "Regular group")
.build()])
.build(),
NodeBuilder::new("communities")
.children([NodeBuilder::new("community")
.attr("id", "120363000000000002@g.us")
.attr("subject", "Parent group")
.build()])
.build(),
])
.build();
let groups = GroupParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
let communities = CommunityParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
assert_eq!(groups.groups.len(), 1);
assert_eq!(groups.groups[0].subject.as_str(), "Regular group");
assert_eq!(communities.groups.len(), 1);
assert_eq!(communities.groups[0].subject.as_str(), "Parent group");
assert!(communities.groups[0].is_parent_group);
}
#[test]
fn community_container_marks_entries_as_parent_groups() {
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new("communities")
.children([NodeBuilder::new("community")
.attr("id", "120363000000000003@g.us")
.attr("subject", "Parent without redundant marker")
.build()])
.build()])
.build();
let groups = GroupParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
let communities = CommunityParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
assert!(groups.groups[0].is_parent_group);
assert!(communities.groups[0].is_parent_group);
}
#[test]
fn participating_iqs_accept_direct_group_children() {
let response = NodeBuilder::new("iq")
.children([
NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("subject", "Regular group")
.build(),
NodeBuilder::new("group")
.attr("id", "120363000000000002@g.us")
.attr("subject", "Parent group")
.children([NodeBuilder::new("parent").build()])
.build(),
])
.build();
let groups = GroupParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
let communities = CommunityParticipatingIq::new()
.parse_response(&response.as_node_ref())
.unwrap();
assert_eq!(groups.groups.len(), 2);
assert_eq!(communities.groups.len(), 1);
assert_eq!(communities.groups[0].subject.as_str(), "Parent group");
}
#[test]
fn participating_iqs_preserve_errors_from_the_selected_shape() {
let malformed_groups = NodeBuilder::new("iq")
.children([
NodeBuilder::new("groups")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("size", GROUP_INFO_PARTICIPANT_LIMIT + 1)
.build()])
.build(),
NodeBuilder::new("communities")
.children([NodeBuilder::new("community")
.attr("id", "120363000000000002@g.us")
.build()])
.build(),
])
.build();
let error = GroupParticipatingIq::new()
.parse_response(&malformed_groups.as_node_ref())
.unwrap_err();
assert!(error.to_string().contains("'size' attribute exceeds"));
let malformed_direct_community = NodeBuilder::new("iq")
.children([
NodeBuilder::new("groups")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000003@g.us")
.build()])
.build(),
NodeBuilder::new("community")
.attr("id", "120363000000000004@g.us")
.children([NodeBuilder::new("evolution_version")
.attr("value", GROUP_EVOLUTION_VERSION_MAX + 1)
.build()])
.build(),
])
.build();
let error = CommunityParticipatingIq::new()
.parse_response(&malformed_direct_community.as_node_ref())
.unwrap_err();
assert!(error.to_string().contains("'value' attribute exceeds"));
}
#[test]
fn test_group_subject_validation() {
let subject = GroupSubject::new("Test Group").unwrap();
assert_eq!(subject.as_str(), "Test Group");
let at_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH);
assert!(GroupSubject::new(&at_limit).is_ok());
let over_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH + 1);
assert!(GroupSubject::new(&over_limit).is_err());
}
#[test]
fn test_group_description_validation() {
let desc = GroupDescription::new("Test Description").unwrap();
assert_eq!(desc.as_str(), "Test Description");
let at_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH);
assert!(GroupDescription::new(&at_limit).is_ok());
let over_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1);
assert!(GroupDescription::new(&over_limit).is_err());
}
#[test]
fn test_string_enum_member_add_mode() {
assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add");
assert_eq!(MemberAddMode::AllMemberAdd.as_str(), "all_member_add");
assert_eq!(
MemberAddMode::try_from("admin_add").unwrap(),
MemberAddMode::AdminAdd
);
assert!(MemberAddMode::try_from("invalid").is_err());
}
#[test]
fn test_string_enum_member_link_mode() {
assert_eq!(MemberLinkMode::AdminLink.as_str(), "admin_link");
assert_eq!(MemberLinkMode::AllMemberLink.as_str(), "all_member_link");
assert_eq!(
MemberLinkMode::try_from("admin_link").unwrap(),
MemberLinkMode::AdminLink
);
}
#[test]
fn test_participant_type_is_admin() {
assert!(!ParticipantType::Member.is_admin());
assert!(ParticipantType::Admin.is_admin());
assert!(ParticipantType::SuperAdmin.is_admin());
}
#[test]
fn test_normalize_participants_drops_phone_for_pn() {
let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
let lid_jid: Jid = "100000000000001@lid".parse().unwrap();
let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap();
let participants = vec![
GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()),
GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()),
];
let normalized = normalize_participants(&participants);
assert!(normalized[0].phone_number.is_none());
assert_eq!(normalized[0].jid, pn_jid);
assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid));
}
#[test]
fn test_build_create_group_node() {
let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap();
let options = GroupCreateOptions::new("Test Subject")
.with_participant(GroupParticipantOptions::from_phone(pn_jid))
.with_member_link_mode(MemberLinkMode::AllMemberLink)
.with_member_add_mode(MemberAddMode::AdminAdd);
let node = build_create_group_node(&options);
assert_eq!(node.tag, "create");
assert_eq!(
node.attrs().optional_string("subject").as_deref(),
Some("Test Subject")
);
let link_mode = node.get_children_by_tag("member_link_mode").next().unwrap();
assert_eq!(
link_mode.content.as_ref().and_then(|c| match c {
NodeContent::String(s) => Some(s.as_str()),
_ => None,
}),
Some("all_member_link")
);
}
#[test]
fn test_typed_builder() {
let options: GroupCreateOptions = GroupCreateOptions::builder()
.subject("My Group")
.member_add_mode(MemberAddMode::AdminAdd)
.build();
assert_eq!(options.subject, "My Group");
assert_eq!(options.member_add_mode, Some(MemberAddMode::AdminAdd));
}
#[test]
fn test_set_group_description_with_id_and_prev() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let desc = GroupDescription::new("New description").unwrap();
let spec = SetGroupDescriptionIq::new(&jid, Some(desc), Some("AABBCCDD"));
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let desc_node = &nodes[0];
assert_eq!(desc_node.tag, "description");
let id = desc_node.attrs().optional_string("id").unwrap();
assert_eq!(id.len(), 8);
assert_eq!(
desc_node.attrs().optional_string("prev").as_deref(),
Some("AABBCCDD")
);
assert!(desc_node.get_children_by_tag("body").next().is_some());
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_set_group_description_delete() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = SetGroupDescriptionIq::new(&jid, None, Some("PREV1234"));
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let desc_node = &nodes[0];
assert_eq!(desc_node.tag, "description");
assert_eq!(
desc_node.attrs().optional_string("delete").as_deref(),
Some("true")
);
assert_eq!(
desc_node.attrs().optional_string("prev").as_deref(),
Some("PREV1234")
);
assert!(desc_node.attrs().optional_string("id").is_some());
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_set_group_description_without_prev_omits_the_attr() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let desc = GroupDescription::new("First description").unwrap();
let iq = SetGroupDescriptionIq::new(&jid, Some(desc), None).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
let desc_node = &nodes[0];
assert_eq!(desc_node.attrs().optional_string("id").unwrap().len(), 8);
assert!(
desc_node.attrs().optional_string("prev").is_none(),
"a group with no description must not carry a prev token"
);
assert!(desc_node.get_children_by_tag("body").next().is_some());
}
#[test]
fn test_set_group_description_delete_without_prev_omits_the_attr() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let iq = SetGroupDescriptionIq::new(&jid, None, None).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
let desc_node = &nodes[0];
assert_eq!(
desc_node.attrs().optional_string("delete").as_deref(),
Some("true")
);
assert!(desc_node.attrs().optional_string("prev").is_none());
}
#[test]
fn test_set_group_description_ids_are_distinct_per_request() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let desc = GroupDescription::new("Description").unwrap();
let first = SetGroupDescriptionIq::new(&jid, Some(desc.clone()), Some("AABBCCDD"));
let second = SetGroupDescriptionIq::new(&jid, Some(desc), Some("AABBCCDD"));
assert_ne!(
first.id, second.id,
"each update must mint a new description id"
);
assert_ne!(
first.id,
first.prev.clone().unwrap(),
"the new id must not reuse the token it replaces"
);
}
#[test]
fn test_leave_group_iq() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = LeaveGroupIq::new(&jid);
let iq = spec.build_iq();
assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
assert_eq!(iq.query_type, InfoQueryType::Set);
assert_eq!(iq.to.server, Server::Group);
}
#[test]
fn test_add_participants_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let p2: Jid = "9876543210@s.whatsapp.net".parse().unwrap();
let spec = AddParticipantsIq::new(&group, &[p1, p2]);
let iq = spec.build_iq();
assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE);
assert_eq!(iq.to, group);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let add_node = &nodes[0];
assert_eq!(add_node.tag, "add");
let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
assert_eq!(participants.len(), 2);
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_add_participants_with_options_privacy() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let p1 = GroupParticipantOptions {
jid: "1234567890@s.whatsapp.net".parse().unwrap(),
phone_number: None,
privacy: Some(vec![0xDE, 0xAD, 0xBE, 0xEF]),
};
let spec = AddParticipantsIq::with_options(&group, vec![p1]);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let add_node = &nodes[0];
assert_eq!(add_node.tag, "add");
let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
assert_eq!(participants.len(), 1);
let privacy_children: Vec<_> = participants[0].get_children_by_tag("privacy").collect();
assert_eq!(privacy_children.len(), 1, "expected a <privacy> child node");
match &privacy_children[0].content {
Some(NodeContent::String(s)) => assert_eq!(s, "deadbeef"),
other => panic!("expected String content in <privacy>, got: {:?}", other),
}
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_add_participants_with_options_no_privacy() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let p1 = GroupParticipantOptions {
jid: "1234567890@s.whatsapp.net".parse().unwrap(),
phone_number: None,
privacy: None,
};
let spec = AddParticipantsIq::with_options(&group, vec![p1]);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let add_node = &nodes[0];
assert_eq!(add_node.tag, "add");
let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
assert_eq!(participants.len(), 1);
let privacy_children: Vec<_> = participants[0].get_children_by_tag("privacy").collect();
assert!(
privacy_children.is_empty(),
"expected no <privacy> child when privacy is None"
);
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_add_participants_strips_phone_number_for_pn_jid() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let pn_jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let p1 = GroupParticipantOptions::new(pn_jid.clone())
.with_phone_number("9876543210@s.whatsapp.net".parse().unwrap());
let spec = AddParticipantsIq::with_options(&group, vec![p1]);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let add_node = &nodes[0];
let participants: Vec<_> = add_node.get_children_by_tag("participant").collect();
assert_eq!(participants.len(), 1);
assert!(
participants[0]
.attrs()
.optional_string("phone_number")
.is_none(),
"phone_number should be stripped for non-LID JIDs"
);
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_remove_participants_including_linked_groups_iq() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let participant: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let spec = RemoveParticipantsIncludingLinkedGroupsIq::new(&parent, &[participant]);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "remove");
assert_eq!(
nodes[0].attrs().optional_string("linked_groups").as_deref(),
Some("true")
);
assert_eq!(nodes[0].get_children_by_tag("participant").count(), 1);
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_promote_demote_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let p1: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let promote = PromoteParticipantsIq::new(&group, std::slice::from_ref(&p1));
let iq = promote.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "promote");
} else {
panic!("expected nodes content");
}
let promote_response = NodeBuilder::new("iq")
.children([NodeBuilder::new("promote")
.children([NodeBuilder::new("participant")
.attr("jid", &p1)
.attr("type", "admin")
.build()])
.build()])
.build();
let promoted = promote
.parse_response(&promote_response.as_node_ref())
.unwrap();
assert_eq!(promoted.len(), 1);
assert_eq!(promoted[0].jid, p1);
assert_eq!(promoted[0].status.as_deref(), Some("admin"));
let demote = DemoteParticipantsIq::new(&group, std::slice::from_ref(&p1));
let iq = demote.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "demote");
} else {
panic!("expected nodes content");
}
let demote_response = NodeBuilder::new("iq")
.children([NodeBuilder::new("demote")
.children([NodeBuilder::new("participant").attr("jid", &p1).build()])
.build()])
.build();
let demoted = demote
.parse_response(&demote_response.as_node_ref())
.unwrap();
assert_eq!(demoted.len(), 1);
assert!(demoted[0].is_ok());
}
#[test]
fn test_get_group_invite_link_iq() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = GetGroupInviteLinkIq::new(&jid, false);
let iq = spec.build_iq();
assert_eq!(iq.query_type, InfoQueryType::Get);
assert_eq!(iq.to, jid);
let reset_spec = GetGroupInviteLinkIq::new(&jid, true);
assert_eq!(reset_spec.build_iq().query_type, InfoQueryType::Set);
}
#[test]
fn test_get_group_invite_link_parse_response() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = GetGroupInviteLinkIq::new(&jid, false);
let response = NodeBuilder::new("response")
.children([NodeBuilder::new("invite")
.attr("code", "AbCdEfGhIjKl")
.build()])
.build();
let result = spec.parse_response(&response.as_node_ref()).unwrap();
assert_eq!(result, "https://chat.whatsapp.com/AbCdEfGhIjKl");
}
#[test]
fn test_participant_change_response_parse_with_type() {
let node = NodeBuilder::new("participant")
.attr("jid", "1234567890@s.whatsapp.net")
.attr("type", "200")
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert_eq!(result.jid.user, "1234567890");
assert_eq!(result.status, Some("200".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_participant_change_response_parse_without_type() {
let node = NodeBuilder::new("participant")
.attr("jid", "1234567890@s.whatsapp.net")
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert_eq!(result.status, None);
assert_eq!(result.error, None);
assert!(result.is_ok());
}
#[test]
fn test_participant_change_response_parse_error() {
let node = NodeBuilder::new("participant")
.attr("jid", "1234567890@s.whatsapp.net")
.attr("error", "403")
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert_eq!(result.error.as_deref(), Some("403"));
assert!(!result.is_ok());
}
#[test]
fn test_participant_change_response_parse_mixins() {
let node = NodeBuilder::new("participant")
.attr("jid", "100000000000001@lid")
.attr("phone_number", "15555550100@s.whatsapp.net")
.attr("username", "example_user")
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert!(result.is_ok());
assert_eq!(
result.phone_number.as_ref().map(|j| j.user.as_str()),
Some("15555550100")
);
assert_eq!(result.username.as_deref(), Some("example_user"));
}
#[test]
fn test_participant_change_response_parses_add_request_on_403() {
let node = NodeBuilder::new("participant")
.attr("jid", "5511999999999@s.whatsapp.net")
.attr("error", "403")
.children([NodeBuilder::new("add_request")
.attr("code", "ABC123DEF")
.attr("expiration", "1735689600")
.build()])
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert_eq!(result.error.as_deref(), Some("403"));
let ar = result
.add_request
.expect("403 response must carry the add_request token");
assert_eq!(ar.code, "ABC123DEF");
assert_eq!(ar.expiration, 1735689600);
}
#[test]
fn test_participant_change_response_no_add_request_on_success() {
let node = NodeBuilder::new("participant")
.attr("jid", "5511999999999@s.whatsapp.net")
.build();
let result = ParticipantChangeResponse::try_from_node(&node).unwrap();
assert!(result.add_request.is_none());
}
#[test]
fn test_participant_change_response_rejects_missing_jid() {
let node = NodeBuilder::new("participant").attr("error", "403").build();
let err = ParticipantChangeResponse::try_from_node(&node)
.expect_err("missing jid must be a hard error");
assert!(err.to_string().contains("missing required 'jid' attribute"));
}
#[test]
fn test_participant_change_response_rejects_malformed_add_request() {
let node = NodeBuilder::new("participant")
.attr("jid", "5511999999999@s.whatsapp.net")
.attr("error", "403")
.children([NodeBuilder::new("add_request")
.attr("expiration", "1735689600")
.build()])
.build();
let err = ParticipantChangeResponse::try_from_node(&node)
.expect_err("missing add_request code must be a hard error");
assert!(err.to_string().contains("missing required 'code'"));
let node = NodeBuilder::new("participant")
.attr("jid", "5511999999999@s.whatsapp.net")
.attr("error", "403")
.children([NodeBuilder::new("add_request")
.attr("code", "ABC")
.attr("expiration", "not-a-number")
.build()])
.build();
let err = ParticipantChangeResponse::try_from_node(&node)
.expect_err("non-u64 expiration must be a hard error");
assert!(err.to_string().contains("'expiration' is not a u64"));
}
#[test]
fn test_set_group_locked_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let lock = SetGroupLockedIq::lock(&group);
let iq = lock.build_iq();
assert_eq!(iq.query_type, InfoQueryType::Set);
assert_eq!(iq.to, group);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "locked");
} else {
panic!("expected nodes content");
}
let unlock = SetGroupLockedIq::unlock(&group);
let iq = unlock.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "unlocked");
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_set_group_announcement_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let announce = SetGroupAnnouncementIq::announce(&group);
let iq = announce.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "announcement");
} else {
panic!("expected nodes content");
}
let not_announce = SetGroupAnnouncementIq::unannounce(&group);
let iq = not_announce.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "not_announcement");
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_set_group_ephemeral_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let enable = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap());
let iq = enable.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "ephemeral");
assert_eq!(
nodes[0].attrs().optional_string("expiration").as_deref(),
Some("86400")
);
} else {
panic!("expected nodes content");
}
let disable = SetGroupEphemeralIq::disable(&group);
let iq = disable.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "not_ephemeral");
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_set_group_ephemeral_iq_with_trigger() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let with_trigger =
SetGroupEphemeralIq::enable_with_trigger(&group, NonZeroU32::new(604800).unwrap(), 7);
let iq = with_trigger.build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
let mut attrs = nodes[0].attrs();
assert_eq!(
attrs.optional_string("expiration").as_deref(),
Some("604800")
);
assert_eq!(attrs.optional_string("trigger").as_deref(), Some("7"));
}
#[test]
fn test_set_group_ephemeral_iq_accepts_trigger_at_max() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let _iq = SetGroupEphemeralIq::enable_with_trigger(
&group,
NonZeroU32::new(86400).unwrap(),
EPHEMERAL_TRIGGER_MAX,
);
}
#[test]
#[should_panic(expected = "ephemeral trigger must be in 0..=20")]
fn test_set_group_ephemeral_iq_rejects_trigger_above_max() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let _ = SetGroupEphemeralIq::enable_with_trigger(
&group,
NonZeroU32::new(86400).unwrap(),
EPHEMERAL_TRIGGER_MAX + 1,
);
}
#[test]
fn test_set_group_ephemeral_iq_without_trigger_omits_attr() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let iq = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap()).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
assert!(
nodes[0].attrs().optional_string("trigger").is_none(),
"default enable() must not emit a trigger attribute"
);
}
#[test]
fn test_set_group_ephemeral_iq_skips_out_of_range_trigger_in_build_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let mut iq_spec = SetGroupEphemeralIq::enable(&group, NonZeroU32::new(86400).unwrap());
iq_spec.trigger = Some(EPHEMERAL_TRIGGER_MAX + 1);
let iq = iq_spec.build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
assert!(
nodes[0].attrs().optional_string("trigger").is_none(),
"out-of-range trigger must be dropped on the wire"
);
}
#[test]
fn test_set_group_membership_approval_iq() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = SetGroupMembershipApprovalIq::new(&group, MembershipApprovalMode::On);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "membership_approval_mode");
let join = nodes[0].get_children_by_tag("group_join").next().unwrap();
assert!(join.attrs.get("state").is_some_and(|v| v == "on"));
} else {
panic!("expected nodes content");
}
}
#[test]
fn participating_groups_accepts_current_and_legacy_envelopes() {
let spec = GroupParticipatingIq::new();
for (container_tag, item_tag) in [("groups", "group"), ("communities", "community")] {
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new(container_tag)
.children([NodeBuilder::new(item_tag)
.attr("id", "120363000000000041@g.us")
.attr("subject", "Fictitious parent")
.children([NodeBuilder::new("parent").build()])
.build()])
.build()])
.build();
let groups = spec.parse_response(&response.as_node_ref()).unwrap().groups;
assert_eq!(groups.len(), 1);
assert!(groups[0].is_parent_group);
}
}
#[test]
fn test_build_create_community_node() {
let options = GroupCreateOptions {
subject: "My Community".to_string(),
is_parent: true,
closed: true,
allow_non_admin_sub_group_creation: true,
create_general_chat: true,
..Default::default()
};
let node = build_create_group_node(&options);
assert_eq!(node.tag, "create");
let parent = node.get_children_by_tag("parent").next().unwrap();
assert_eq!(
parent
.attrs()
.optional_string("default_membership_approval_mode")
.as_deref(),
Some("request_required")
);
assert!(
node.get_children_by_tag("allow_non_admin_sub_group_creation")
.next()
.is_some()
);
assert!(
node.get_children_by_tag("create_general_chat")
.next()
.is_some()
);
}
#[test]
fn test_build_create_non_community_omits_parent() {
let options = GroupCreateOptions {
subject: "Regular Group".to_string(),
is_parent: false,
..Default::default()
};
let node = build_create_group_node(&options);
assert!(
node.get_children_by_tag("parent").next().is_none(),
"non-community group should not have <parent>"
);
}
#[test]
fn test_link_subgroups_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub));
let iq = spec.build_iq();
assert_eq!(iq.to, parent);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let links = &nodes[0];
assert_eq!(links.tag, "links");
let link = links.get_children_by_tag("link").next().unwrap();
assert_eq!(
link.attrs().optional_string("link_type").as_deref(),
Some("sub_group")
);
let group = link.get_children_by_tag("group").next().unwrap();
assert_eq!(group.attrs().optional_jid("jid"), Some(sub));
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_link_subgroups_iq_parse_response() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new("links")
.children([NodeBuilder::new("link")
.attr("link_type", "sub_group")
.children([NodeBuilder::new("group")
.attr("jid", sub.to_string())
.build()])
.build()])
.build()])
.build();
let spec = LinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub));
let result = spec.parse_response(&response.as_node_ref()).unwrap();
assert_eq!(result.groups.len(), 1);
assert_eq!(result.groups[0].jid, sub);
assert!(result.groups[0].error.is_none());
}
#[test]
fn test_unlink_subgroups_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), true);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let unlink = &nodes[0];
assert_eq!(unlink.tag, "unlink");
assert_eq!(
unlink.attrs().optional_string("unlink_type").as_deref(),
Some("sub_group")
);
let group = unlink.get_children_by_tag("group").next().unwrap();
assert_eq!(group.attrs().optional_jid("jid"), Some(sub));
assert_eq!(
group
.attrs()
.optional_string("remove_orphaned_members")
.as_deref(),
Some("true")
);
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_unlink_subgroups_iq_parse_response_with_error() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let response = NodeBuilder::new("iq")
.children([NodeBuilder::new("unlink")
.attr("unlink_type", "sub_group")
.children([NodeBuilder::new("group")
.attr("jid", sub.to_string())
.attr("error", "406")
.build()])
.build()])
.build();
let spec = UnlinkSubgroupsIq::new(&parent, std::slice::from_ref(&sub), false);
let result = spec.parse_response(&response.as_node_ref()).unwrap();
assert_eq!(result.groups.len(), 1);
assert_eq!(result.groups[0].jid, sub);
assert_eq!(result.groups[0].error, Some(406));
}
#[test]
fn test_delete_community_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = DeleteCommunityIq::new(&parent);
let iq = spec.build_iq();
assert_eq!(iq.to, parent);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "delete_parent");
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_query_linked_group_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let spec = QueryLinkedGroupIq::new(&parent, &sub);
let iq = spec.build_iq();
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let query = &nodes[0];
assert_eq!(query.tag, "query_linked");
assert_eq!(
query.attrs().optional_string("type").as_deref(),
Some("sub_group")
);
assert_eq!(query.attrs().optional_jid("jid"), Some(sub));
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_join_linked_group_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let sub: Jid = "120363000000000002@g.us".parse().unwrap();
let spec = JoinLinkedGroupIq::new(&parent, &sub);
let iq = spec.build_iq();
assert_eq!(iq.to, parent);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
let join = &nodes[0];
assert_eq!(join.tag, "join_linked_group");
assert_eq!(join.attrs().optional_jid("jid"), Some(sub));
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_get_linked_groups_participants_iq_build() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let spec = GetLinkedGroupsParticipantsIq::new(&parent);
let iq = spec.build_iq();
assert_eq!(iq.to, parent);
if let Some(NodeContent::Nodes(nodes)) = &iq.content {
assert_eq!(nodes[0].tag, "linked_groups_participants");
} else {
panic!("expected nodes content");
}
}
#[test]
fn test_group_info_response_parses_community_fields() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("subject", "My Community")
.children([
NodeBuilder::new("parent").build(),
NodeBuilder::new("allow_non_admin_sub_group_creation").build(),
])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert!(response.is_parent_group);
assert!(response.allow_non_admin_sub_group_creation);
assert!(response.parent_group_jid.is_none());
assert!(!response.is_default_sub_group);
assert!(!response.is_general_chat);
}
#[test]
fn test_group_info_response_parses_subgroup_fields() {
let parent_jid = "120363000000000001@g.us";
let node = NodeBuilder::new("group")
.attr("id", "120363000000000002@g.us")
.attr("subject", "Sub Group")
.children([
NodeBuilder::new("linked_parent")
.attr("jid", parent_jid)
.build(),
NodeBuilder::new("default_sub_group").build(),
])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert!(!response.is_parent_group);
assert!(response.is_default_sub_group);
assert_eq!(response.parent_group_jid, Some(parent_jid.parse().unwrap()));
}
#[test]
fn test_group_info_response_parses_description_from_body() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("subject", "Test Group")
.children([NodeBuilder::new("description")
.attr("id", "desc123")
.attr("participant", "5511999999999@s.whatsapp.net")
.attr("t", "1700000000")
.children([NodeBuilder::new("body")
.apply_content(Some(NodeContent::String("Hello world".into())))
.build()])
.build()])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert_eq!(response.description.as_deref(), Some("Hello world"));
assert_eq!(response.description_id.as_deref(), Some("desc123"));
assert_eq!(
response.description_owner,
Some("5511999999999@s.whatsapp.net".parse().unwrap())
);
assert_eq!(response.description_time, Some(1700000000));
}
#[test]
fn test_group_info_response_preserves_optional_wire_metadata() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000010@g.us")
.attr("subject", "Protocol fixture")
.attr("notify", "Fixture notification")
.attr("addressing_mode", "lid")
.attr("creator", "100000000000010@lid")
.attr("creator_pn", "15550000010@s.whatsapp.net")
.attr("creator_username", "fixture.creator")
.attr("creator_country_code", "US")
.attr("p_v_id", "participants-v1")
.attr("a_v_id", "admins-v1")
.attr("open_thread_id", "thread-v1")
.attr("size", 2u32)
.attr("s_o", "100000000000011@lid")
.attr("s_o_pn", "15550000011@s.whatsapp.net")
.attr("s_o_username", "fixture.subject")
.children([
NodeBuilder::new("missing_participant_identification").build(),
NodeBuilder::new("parent")
.attr("default_membership_approval_mode", "request_required")
.build(),
NodeBuilder::new("support").build(),
NodeBuilder::new("suspended")
.attr("can_auto_file", "true")
.build(),
NodeBuilder::new("appeal_status")
.attr("type", "in_review")
.build(),
NodeBuilder::new("appeal_update_time")
.attr("value", 1_700_000_099u64)
.build(),
NodeBuilder::new("auto_add_disabled").build(),
NodeBuilder::new("capi").build(),
NodeBuilder::new("evolution_version")
.attr("value", GROUP_EVOLUTION_VERSION_MAX)
.build(),
NodeBuilder::new("group_safety_check").build(),
NodeBuilder::new("participant_label_enabled").build(),
NodeBuilder::new("limit_sharing_enabled")
.attr("trigger", GROUP_SETTING_TRIGGER_MAX)
.build(),
NodeBuilder::new("description")
.attr("id", "fixture-description")
.attr("participant", "100000000000012@lid")
.attr("participant_pn", "15550000012@s.whatsapp.net")
.attr("participant_username", "fixture.description")
.attr("t", 1_700_000_012u64)
.children([NodeBuilder::new("body")
.string_content("Fixture description")
.build()])
.build(),
NodeBuilder::new("ephemeral")
.attr("expiration", 0u32)
.attr("trigger", 4u32)
.build(),
NodeBuilder::new("participant")
.attr("jid", "100000000000013@lid")
.attr("phone_number", "15550000013@s.whatsapp.net")
.attr("username", "fixture.member")
.attr("type", "superadmin")
.attr("participant_label", "organizer")
.attr("participant_label_mtime", 1_700_000_013u64)
.attr("join_time", 1_700_000_014u64)
.attr("group_history_sent", "0")
.attr("display_name", "Fixture Member")
.attr("addressable", "0")
.build(),
NodeBuilder::new("participant")
.attr("jid", "15550000014@s.whatsapp.net")
.attr("lid", "100000000000014@lid")
.attr("username", "fixture.fallback")
.attr("type", "admin")
.build(),
])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert_eq!(response.notify.as_deref(), Some("Fixture notification"));
assert_eq!(
response.creator_pn,
Some("15550000010@s.whatsapp.net".parse().unwrap())
);
assert_eq!(
response.creator_username.as_deref(),
Some("fixture.creator")
);
assert_eq!(response.creator_country_code.as_deref(), Some("US"));
assert_eq!(
response.participant_version_id.as_deref(),
Some("participants-v1")
);
assert_eq!(response.admin_version_id.as_deref(), Some("admins-v1"));
assert_eq!(response.open_thread_id.as_deref(), Some("thread-v1"));
assert!(response.has_missing_participant_identification);
assert!(response.parent_membership_approval_required);
assert!(response.is_support_group);
assert!(response.is_suspended);
assert!(response.suspension_can_auto_file);
assert_eq!(response.appeal_status, Some(GroupAppealStatus::InReview));
assert_eq!(response.appeal_update_time, Some(1_700_000_099));
assert!(response.is_auto_add_disabled);
assert!(response.has_capi);
assert_eq!(
response.evolution_version,
Some(GROUP_EVOLUTION_VERSION_MAX)
);
assert!(response.has_group_safety_check);
assert!(response.participant_label_enabled);
assert!(response.is_limit_sharing_enabled);
assert_eq!(
response.limit_sharing_trigger,
Some(GROUP_SETTING_TRIGGER_MAX)
);
assert_eq!(
response.subject_owner_pn,
Some("15550000011@s.whatsapp.net".parse().unwrap())
);
assert_eq!(
response.subject_owner_username.as_deref(),
Some("fixture.subject")
);
assert_eq!(
response.description_owner_pn,
Some("15550000012@s.whatsapp.net".parse().unwrap())
);
assert_eq!(
response.description_owner_username.as_deref(),
Some("fixture.description")
);
assert_eq!(
response.ephemeral,
Some(GroupEphemeralSettings {
expiration: Some(0),
trigger: Some(4),
})
);
assert_eq!(
response.participants[0].participant_type,
ParticipantType::SuperAdmin
);
assert_eq!(
response.participants[0].phone_number,
Some("15550000013@s.whatsapp.net".parse().unwrap())
);
assert_eq!(
response.participants[0].username.as_deref(),
Some("fixture.member")
);
let details = response.participants[0]
.details
.as_deref()
.expect("participant details");
assert_eq!(details.participant_label.as_deref(), Some("organizer"));
assert_eq!(details.participant_label_mtime, Some(1_700_000_013));
assert_eq!(details.join_time, Some(1_700_000_014));
assert_eq!(details.group_history_sent, Some(false));
assert_eq!(details.display_name.as_deref(), Some("Fixture Member"));
assert!(!details.is_addressable);
assert_eq!(
response.participants[1].lid,
Some("100000000000014@lid".parse().unwrap())
);
assert_eq!(
response.participants[1].username.as_deref(),
Some("fixture.fallback")
);
let round_trip = GroupInfoResponse::try_from_node(&response.into_node()).unwrap();
assert_eq!(round_trip.ephemeral.unwrap().expiration, Some(0));
assert_eq!(
round_trip.description_owner_username.as_deref(),
Some("fixture.description")
);
assert_eq!(
round_trip.participants[0].participant_type,
ParticipantType::SuperAdmin
);
assert_eq!(
round_trip.participants[0].username.as_deref(),
Some("fixture.member")
);
assert_eq!(
round_trip.limit_sharing_trigger,
Some(GROUP_SETTING_TRIGGER_MAX)
);
assert!(round_trip.has_missing_participant_identification);
}
#[test]
fn group_info_treats_non_request_parent_mode_as_open() {
let node = NodeBuilder::new("community")
.attr("id", "120363000000000013@g.us")
.children([NodeBuilder::new("parent")
.attr("default_membership_approval_mode", "auto_approve")
.build()])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert!(response.is_parent_group);
assert!(!response.parent_membership_approval_required);
}
#[test]
fn participant_details_accept_protocol_boolean_forms() {
for (wire, expected) in [("0", false), ("1", true)] {
let node = NodeBuilder::new("participant")
.attr("jid", "100000000000015@lid")
.attr("group_history_sent", wire)
.attr("addressable", wire)
.build();
let participant = GroupParticipantResponse::try_from_node(&node).unwrap();
let details = participant.details.expect("boolean details");
assert_eq!(details.group_history_sent, Some(expected));
assert_eq!(details.is_addressable, expected);
}
let invalid = NodeBuilder::new("participant")
.attr("jid", "100000000000015@lid")
.attr("addressable", "sometimes")
.build();
assert!(GroupParticipantResponse::try_from_node(&invalid).is_err());
}
#[test]
fn test_group_info_response_accepts_explicit_false_suspension_flag() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000015@g.us")
.attr("subject", "Suspended Group")
.children([NodeBuilder::new("suspended")
.attr("can_auto_file", "false")
.build()])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert!(response.is_suspended);
assert!(!response.suspension_can_auto_file);
}
#[test]
fn test_group_info_response_distinguishes_absent_and_empty_ephemeral_nodes() {
let without_ephemeral = NodeBuilder::new("group")
.attr("id", "120363000000000020@g.us")
.attr("subject", "No ephemeral node")
.build();
let with_empty_ephemeral = NodeBuilder::new("group")
.attr("id", "120363000000000021@g.us")
.attr("subject", "Empty ephemeral node")
.children([NodeBuilder::new("ephemeral").build()])
.build();
let absent = GroupInfoResponse::try_from_node(&without_ephemeral).unwrap();
let empty = GroupInfoResponse::try_from_node(&with_empty_ephemeral).unwrap();
assert!(absent.ephemeral.is_none());
assert_eq!(empty.ephemeral, Some(GroupEphemeralSettings::default()));
assert!(empty.into_node().get_optional_child("ephemeral").is_some());
}
#[test]
fn test_group_info_response_rejects_out_of_range_metadata() {
let fixtures = [
NodeBuilder::new("group")
.attr("id", "120363000000000031@g.us")
.attr("size", GROUP_INFO_PARTICIPANT_LIMIT + 1)
.build(),
NodeBuilder::new("group")
.attr("id", "120363000000000032@g.us")
.children([NodeBuilder::new("ephemeral")
.attr("expiration", u64::from(GROUP_EPHEMERAL_EXPIRATION_MAX) + 1)
.build()])
.build(),
NodeBuilder::new("group")
.attr("id", "120363000000000033@g.us")
.children([NodeBuilder::new("ephemeral")
.attr("trigger", GROUP_SETTING_TRIGGER_MAX + 1)
.build()])
.build(),
NodeBuilder::new("group")
.attr("id", "120363000000000034@g.us")
.children([NodeBuilder::new("evolution_version")
.attr("value", GROUP_EVOLUTION_VERSION_MAX + 1)
.build()])
.build(),
NodeBuilder::new("group")
.attr("id", "120363000000000035@g.us")
.children([NodeBuilder::new("limit_sharing_enabled")
.attr("trigger", GROUP_SETTING_TRIGGER_MAX + 1)
.build()])
.build(),
];
for fixture in fixtures {
assert!(
GroupInfoResponse::try_from_node(&fixture).is_err(),
"out-of-range group metadata must be rejected: {fixture:?}"
);
}
}
#[test]
fn test_group_info_response_serializes_description_identity_without_body() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000030@g.us")
.attr("subject", "Description identity")
.children([NodeBuilder::new("description")
.attr("participant_pn", "15550000030@s.whatsapp.net")
.attr("participant_username", "fixture.description.only")
.build()])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
let serialized = response.into_node();
let description = serialized
.get_optional_child("description")
.expect("description identity attributes must retain their node");
assert_eq!(
description.attrs().optional_jid("participant_pn"),
Some("15550000030@s.whatsapp.net".parse().unwrap())
);
assert_eq!(
description
.attrs()
.optional_string("participant_username")
.as_deref(),
Some("fixture.description.only")
);
}
#[test]
fn test_group_create_iq_overlays_parent_flags() {
let options = GroupCreateOptions {
subject: "My Community".into(),
is_parent: true,
closed: true,
allow_non_admin_sub_group_creation: true,
..Default::default()
};
let spec = GroupCreateIq::new(options);
let iq = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001")
.attr("subject", "My Community")
.build()])
.build();
let response = spec.parse_response(&iq.as_node_ref()).unwrap();
assert!(response.is_parent_group);
assert!(response.parent_membership_approval_required);
assert!(response.allow_non_admin_sub_group_creation);
}
#[test]
fn test_group_create_iq_overlay_does_not_elevate_false_flag() {
let options = GroupCreateOptions {
subject: "Closed Community".into(),
is_parent: true,
allow_non_admin_sub_group_creation: false,
..Default::default()
};
let spec = GroupCreateIq::new(options);
let iq = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001")
.attr("subject", "Closed Community")
.build()])
.build();
let response = spec.parse_response(&iq.as_node_ref()).unwrap();
assert!(response.is_parent_group);
assert!(!response.allow_non_admin_sub_group_creation);
}
#[test]
fn test_group_create_iq_overlay_preserves_server_true() {
let options = GroupCreateOptions {
subject: "Community".into(),
is_parent: true,
allow_non_admin_sub_group_creation: false,
..Default::default()
};
let spec = GroupCreateIq::new(options);
let iq = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001")
.attr("subject", "Community")
.children([NodeBuilder::new("allow_non_admin_sub_group_creation").build()])
.build()])
.build();
let response = spec.parse_response(&iq.as_node_ref()).unwrap();
assert!(response.is_parent_group);
assert!(response.allow_non_admin_sub_group_creation);
}
#[test]
fn test_group_create_iq_emits_linked_parent() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let options = GroupCreateOptions {
subject: "Subgroup".into(),
linked_parent: Some(parent.clone()),
..Default::default()
};
let iq = GroupCreateIq::new(options).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected <create>");
};
let linked = nodes[0]
.get_optional_child("linked_parent")
.expect("linked_parent child must be emitted");
assert_eq!(
linked.attrs().jid("jid"),
parent,
"linked_parent jid must match the requested parent"
);
}
#[test]
#[cfg_attr(debug_assertions, should_panic(expected = "mutually exclusive"))]
fn test_group_create_iq_linked_parent_excludes_parent_block() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let options = GroupCreateOptions {
subject: "Conflicting".into(),
is_parent: true,
closed: true,
allow_non_admin_sub_group_creation: true,
create_general_chat: true,
linked_parent: Some(parent.clone()),
..Default::default()
};
let iq = GroupCreateIq::new(options).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected <create>");
};
assert!(nodes[0].get_optional_child("linked_parent").is_some());
assert!(nodes[0].get_optional_child("parent").is_none());
assert!(
nodes[0]
.get_optional_child("allow_non_admin_sub_group_creation")
.is_none()
);
assert!(nodes[0].get_optional_child("create_general_chat").is_none());
}
#[test]
fn test_group_create_iq_parse_response_does_not_promote_subgroup_to_parent() {
let parent: Jid = "120363000000000001@g.us".parse().unwrap();
let options = GroupCreateOptions {
subject: "Subgroup".into(),
is_parent: true,
allow_non_admin_sub_group_creation: true,
linked_parent: Some(parent.clone()),
..Default::default()
};
let spec = GroupCreateIq::new(options);
let iq = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363999999999999")
.attr("subject", "Subgroup")
.children([NodeBuilder::new("linked_parent")
.attr("jid", &parent)
.build()])
.build()])
.build();
let response = spec.parse_response(&iq.as_node_ref()).unwrap();
assert!(!response.is_parent_group);
assert_eq!(response.parent_group_jid, Some(parent));
assert!(!response.allow_non_admin_sub_group_creation);
}
#[test]
fn test_group_create_iq_emits_description_with_body() {
let options = GroupCreateOptions {
subject: "Group with desc".into(),
description: Some(GroupDescription::new("Hello, group").unwrap()),
..Default::default()
};
let iq = GroupCreateIq::new(options).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected <create>");
};
let desc = nodes[0]
.get_optional_child("description")
.expect("description child must be emitted");
assert!(
desc.attrs()
.optional_string("id")
.is_some_and(|id| !id.is_empty()),
"description must carry an opaque id token"
);
let body = desc
.get_optional_child("body")
.expect("description must have a body child");
let text = match &body.content {
Some(NodeContent::String(s)) => s.to_string(),
Some(NodeContent::Bytes(b)) => String::from_utf8_lossy(b).into_owned(),
_ => panic!("description body must carry text"),
};
assert_eq!(text, "Hello, group");
}
#[test]
fn test_group_create_iq_description_rejects_over_max_length() {
let too_long = "x".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1);
assert!(GroupDescription::new(too_long).is_err());
}
#[test]
fn test_group_create_iq_omits_linked_parent_and_description_by_default() {
let iq = GroupCreateIq::new(GroupCreateOptions::new("Plain")).build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected <create>");
};
assert!(nodes[0].get_optional_child("linked_parent").is_none());
assert!(nodes[0].get_optional_child("description").is_none());
}
#[test]
fn test_group_create_iq_no_overlay_for_plain_group() {
let options = GroupCreateOptions {
subject: "Plain Group".into(),
is_parent: false,
allow_non_admin_sub_group_creation: false,
..Default::default()
};
let spec = GroupCreateIq::new(options);
let iq = NodeBuilder::new("iq")
.children([NodeBuilder::new("group")
.attr("id", "120363000000000001")
.attr("subject", "Plain Group")
.build()])
.build();
let response = spec.parse_response(&iq.as_node_ref()).unwrap();
assert!(!response.is_parent_group);
assert!(!response.allow_non_admin_sub_group_creation);
}
#[test]
fn test_group_info_response_parses_create_response() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000001")
.attr("addressing_mode", "lid")
.attr("subject", "test")
.attr("creator", "100000000000001@lid")
.attr("creation", "1700000000")
.attr("s_t", "1700000000")
.attr("s_o", "100000000000001@lid")
.children([
NodeBuilder::new("ephemeral")
.attr("expiration", 0u32)
.build(),
NodeBuilder::new("member_link_mode")
.string_content("admin_link")
.build(),
NodeBuilder::new("member_add_mode")
.string_content("all_member_add")
.build(),
NodeBuilder::new("member_share_group_history_mode")
.string_content("all_member_share")
.build(),
NodeBuilder::new("participant")
.attr("jid", "100000000000001@lid")
.attr("type", "superadmin")
.attr("phone_number", "5511999999999@s.whatsapp.net")
.build(),
NodeBuilder::new("participant")
.attr("jid", "100000000000002@lid")
.attr("phone_number", "5511988888888@s.whatsapp.net")
.build(),
])
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert_eq!(response.id.to_string(), "120363000000000001@g.us");
assert_eq!(response.subject.as_str(), "test");
assert_eq!(response.addressing_mode, AddressingMode::Lid);
assert_eq!(response.creation_time, Some(1700000000));
assert_eq!(response.subject_time, Some(1700000000));
assert_eq!(response.member_link_mode, Some(MemberLinkMode::AdminLink));
assert_eq!(response.member_add_mode, Some(MemberAddMode::AllMemberAdd));
assert_eq!(
response.member_share_history_mode,
Some(MemberShareHistoryMode::AllMemberShare)
);
assert_eq!(response.participants.len(), 2);
assert!(response.description.is_none());
assert!(!response.is_locked);
assert!(!response.is_announcement);
assert!(!response.is_parent_group);
assert!(response.size.is_none());
}
#[test]
fn test_group_info_response_no_description() {
let node = NodeBuilder::new("group")
.attr("id", "120363000000000001@g.us")
.attr("subject", "Test Group")
.build();
let response = GroupInfoResponse::try_from_node(&node).unwrap();
assert!(response.description.is_none());
assert!(response.description_id.is_none());
assert!(response.description_owner.is_none());
assert!(response.description_time.is_none());
}
#[test]
fn test_accept_group_invite_v4_iq_attrs() {
let group_jid: Jid = "120363000000000042@g.us".parse().unwrap();
let admin_jid: Jid = "5511999887766@s.whatsapp.net".parse().unwrap();
let code = "A1B2C3D4".to_string();
let expiration: i64 = 1_700_000_123;
let spec = AcceptGroupInviteV4Iq::new(&group_jid, &code, expiration, &admin_jid);
let iq = spec.build_iq();
assert_eq!(iq.to, group_jid);
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected nodes content");
};
let accept = &nodes[0];
assert_eq!(accept.tag, "accept");
assert_eq!(
accept.attrs().optional_string("code").as_deref(),
Some(code.as_str()),
);
assert_eq!(
accept.attrs().optional_string("expiration").as_deref(),
Some("1700000123"),
);
assert_eq!(
accept.attrs().optional_string("admin").as_deref(),
Some("5511999887766@s.whatsapp.net"),
);
}
}