use crate::WireEnum;
use serde::Serialize;
use wacore_binary::Jid;
use wacore_binary::{Node, NodeRef};
const MISSING_PARTICIPANT_IDENTIFICATION_TAG: &str = "missing_participant_identification";
#[derive(Debug, Clone, PartialEq, Eq, WireEnum)]
pub enum MembershipRequestMethod {
#[wire_default]
#[wire = "invite_link"]
InviteLink,
#[wire = "linked_group_join"]
LinkedGroupJoin,
#[wire = "non_admin_add"]
NonAdminAdd,
}
#[derive(Debug, Clone)]
pub struct GroupNotification {
pub group_jid: Jid,
pub notification_id: Option<String>,
pub notify: Option<String>,
pub offline: Option<String>,
pub participant: Option<Jid>,
pub participant_pn: Option<Jid>,
pub participant_username: Option<String>,
pub participant_country_code: Option<String>,
pub timestamp: u64,
pub is_lid_addressing_mode: bool,
pub has_incomplete_participant_information: bool,
pub actions: Vec<GroupNotificationAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum GroupParticipantType {
#[wire_default]
#[wire = "participant"]
Participant,
#[wire = "admin"]
Admin,
#[wire = "superadmin"]
SuperAdmin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum GroupHistorySentState {
#[wire = "HISTORY_NOT_SENT"]
HistoryNotSent,
#[wire = "HISTORY_SENT"]
HistorySent,
#[wire = "NOTICE_SENT"]
NoticeSent,
}
#[derive(Debug, Clone, Serialize)]
pub struct GroupParticipantInfo {
pub jid: Jid,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<Jid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<GroupParticipantType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lid: Option<Jid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub join_time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_history_sent_state: Option<GroupHistorySentState>,
}
#[derive(Debug, Clone, WireEnum)]
#[wire(tag = "type")]
pub enum GroupNotificationAction {
#[wire = "add"]
Add {
participants: Vec<GroupParticipantInfo>,
reason: Option<String>,
},
#[wire = "remove"]
Remove {
participants: Vec<GroupParticipantInfo>,
reason: Option<String>,
},
#[wire = "promote"]
Promote {
participants: Vec<GroupParticipantInfo>,
},
#[wire = "demote"]
Demote {
participants: Vec<GroupParticipantInfo>,
},
#[wire = "modify"]
Modify {
participants: Vec<GroupParticipantInfo>,
},
#[wire = "subject"]
Subject {
subject: String,
subject_owner: Option<Jid>,
subject_time: Option<u64>,
},
#[wire = "description"]
Description {
id: String,
description: Option<String>,
},
#[wire = "locked"]
Locked { threshold: Option<String> },
#[wire = "unlocked"]
Unlocked,
#[wire = "announcement"]
Announce,
#[wire = "not_announcement"]
NotAnnounce,
#[wire = "ephemeral"]
#[wire_alias = "not_ephemeral"]
Ephemeral {
expiration: u32,
trigger: Option<u32>,
},
#[wire = "membership_approval_mode"]
MembershipApprovalMode { enabled: bool },
#[wire = "membership_approval_request"]
MembershipApprovalRequest {
request_method: MembershipRequestMethod,
parent_group_jid: Option<Jid>,
},
#[wire = "created_membership_requests"]
CreatedMembershipRequests {
request_method: MembershipRequestMethod,
parent_group_jid: Option<Jid>,
requests: Vec<GroupParticipantInfo>,
},
#[wire = "revoked_membership_requests"]
RevokedMembershipRequests { participants: Vec<Jid> },
#[wire = "member_add_mode"]
MemberAddMode { mode: String },
#[wire = "no_frequently_forwarded"]
NoFrequentlyForwarded,
#[wire = "frequently_forwarded_ok"]
FrequentlyForwardedOk,
#[wire = "invite"]
Invite { code: String },
#[wire = "revoke"]
RevokeInvite,
#[wire = "growth_locked"]
GrowthLocked { expiration: u32, lock_type: String },
#[wire = "growth_unlocked"]
GrowthUnlocked,
#[wire = "create"]
Create {
#[wire(skip)]
raw: Node,
},
#[wire = "delete"]
Delete { reason: Option<String> },
#[wire = "link"]
Link {
link_type: String,
#[wire(skip)]
raw: Node,
},
#[wire = "unlink"]
Unlink {
unlink_type: String,
unlink_reason: Option<String>,
#[wire(skip)]
raw: Node,
},
#[wire = "linked_group_promote"]
LinkedGroupPromote {
participants: Vec<GroupParticipantInfo>,
},
#[wire = "linked_group_demote"]
LinkedGroupDemote {
participants: Vec<GroupParticipantInfo>,
},
#[wire = "suspended"]
Suspended,
#[wire = "unsuspended"]
Unsuspended,
#[wire = "auto_add_disabled"]
AutoAddDisabled,
#[wire = "is_capi_hosted_group"]
IsCapiHostedGroup,
#[wire = "group_safety_check"]
GroupSafetyCheck,
#[wire = "limit_sharing_enabled"]
LimitSharingEnabled { trigger: Option<u32> },
#[wire = "allow_admin_reports"]
AllowAdminReports,
#[wire = "not_allow_admin_reports"]
NotAllowAdminReports,
#[wire = "reports"]
Reports,
#[wire = "allow_non_admin_sub_group_creation"]
AllowNonAdminSubGroupCreation,
#[wire = "not_allow_non_admin_sub_group_creation"]
NotAllowNonAdminSubGroupCreation,
#[wire = "created_sub_group_suggestion"]
CreatedSubGroupSuggestion {
#[wire(skip)]
raw: Node,
},
#[wire = "revoked_sub_group_suggestions"]
RevokedSubGroupSuggestions {
#[wire(skip)]
raw: Node,
},
#[wire = "change_number"]
ChangeNumber {
new_owner: Option<Jid>,
sub_group_suggestions: Vec<Jid>,
},
#[wire_fallback]
Unknown { tag: String },
}
impl GroupNotification {
pub fn try_from_node_ref(node: &NodeRef<'_>) -> Option<Self> {
let mut attrs = node.attrs();
let group_jid = attrs.optional_jid("from")?;
let notification_id = attrs.optional_string("id").map(|value| value.into_owned());
let notify = attrs
.optional_string("notify")
.map(|value| value.into_owned());
let offline = attrs
.optional_string("offline")
.map(|value| value.into_owned());
let participant = attrs.optional_jid("participant");
let participant_pn = attrs.optional_jid("participant_pn");
let participant_username = attrs
.optional_string("participant_username")
.map(|value| value.into_owned());
let participant_country_code = attrs
.optional_string("participant_country_code")
.map(|value| value.into_owned());
let timestamp = attrs.optional_u64("t").unwrap_or(0);
let is_lid_addressing_mode = node
.get_attr("addressing_mode")
.map(|v| v.as_str())
.is_some_and(|s| s == "lid");
let mut has_incomplete_participant_information = false;
let actions = node
.children()
.map(|children| {
let mut actions = Vec::with_capacity(children.len());
for child in children {
if child.tag.as_ref() == MISSING_PARTICIPANT_IDENTIFICATION_TAG {
has_incomplete_participant_information = true;
} else if let Some(action) = parse_action(child) {
actions.push(action);
}
}
actions
})
.unwrap_or_default();
Some(Self {
group_jid,
notification_id,
notify,
offline,
participant,
participant_pn,
participant_username,
participant_country_code,
timestamp,
is_lid_addressing_mode,
has_incomplete_participant_information,
actions,
})
}
}
fn parse_action(node: &NodeRef<'_>) -> Option<GroupNotificationAction> {
use GroupNotificationActionTag as T;
use wacore_binary::NodeContentRef;
if node.tag.as_ref() == MISSING_PARTICIPANT_IDENTIFICATION_TAG {
return None;
}
let tag = T::from(node.tag.as_ref());
let action = match tag {
T::Add => GroupNotificationAction::Add {
participants: parse_participants(node),
reason: node
.attrs()
.optional_string("reason")
.map(|s| s.into_owned()),
},
T::Remove => GroupNotificationAction::Remove {
participants: parse_participants(node),
reason: node
.attrs()
.optional_string("reason")
.map(|s| s.into_owned()),
},
T::Promote => GroupNotificationAction::Promote {
participants: parse_participants(node),
},
T::Demote => GroupNotificationAction::Demote {
participants: parse_participants(node),
},
T::Modify => GroupNotificationAction::Modify {
participants: parse_participants(node),
},
T::Subject => GroupNotificationAction::Subject {
subject: node
.attrs()
.optional_string("subject")
.as_deref()
.unwrap_or_default()
.to_string(),
subject_owner: node.attrs().optional_jid("s_o"),
subject_time: node.attrs().optional_u64("s_t"),
},
T::Description => {
let id = node
.attrs()
.optional_string("id")
.as_deref()
.unwrap_or_default()
.to_string();
let description = if node.get_optional_child("delete").is_some() {
None
} else {
node.get_optional_child("body")
.and_then(|body| body.content_as_string())
.map(|s| s.to_string())
};
GroupNotificationAction::Description { id, description }
}
T::Locked => GroupNotificationAction::Locked {
threshold: node
.attrs()
.optional_string("threshold")
.map(|s| s.into_owned()),
},
T::Unlocked => GroupNotificationAction::Unlocked,
T::Announce => GroupNotificationAction::Announce,
T::NotAnnounce => GroupNotificationAction::NotAnnounce,
T::Ephemeral => GroupNotificationAction::Ephemeral {
expiration: node
.attrs()
.optional_u64("expiration")
.and_then(|t| t.try_into().ok())
.unwrap_or(0),
trigger: node
.attrs()
.optional_u64("trigger")
.and_then(|t| t.try_into().ok()),
},
T::MembershipApprovalMode => {
let enabled = node
.get_optional_child("group_join")
.and_then(|gj| gj.attrs().optional_string("state"))
.is_some_and(|s| s == "on");
GroupNotificationAction::MembershipApprovalMode { enabled }
}
T::MembershipApprovalRequest => {
let request_method = parse_request_method(node);
let parent_group_jid = node.attrs().optional_jid("parent_group_jid");
GroupNotificationAction::MembershipApprovalRequest {
request_method,
parent_group_jid,
}
}
T::CreatedMembershipRequests => {
let request_method = parse_request_method(node);
let parent_group_jid = node.attrs().optional_jid("parent_group_jid");
let requests = parse_requested_users(node);
GroupNotificationAction::CreatedMembershipRequests {
request_method,
parent_group_jid,
requests,
}
}
T::RevokedMembershipRequests => {
let participants = parse_participant_jids(node);
GroupNotificationAction::RevokedMembershipRequests { participants }
}
T::MemberAddMode => {
let mode = match node.content.as_ref() {
Some(NodeContentRef::String(s)) => s.to_string(),
Some(NodeContentRef::Bytes(b)) => String::from_utf8_lossy(b.as_ref()).into_owned(),
_ => String::new(),
};
GroupNotificationAction::MemberAddMode { mode }
}
T::NoFrequentlyForwarded => GroupNotificationAction::NoFrequentlyForwarded,
T::FrequentlyForwardedOk => GroupNotificationAction::FrequentlyForwardedOk,
T::Invite => GroupNotificationAction::Invite {
code: node
.attrs()
.optional_string("code")
.as_deref()
.unwrap_or_default()
.to_string(),
},
T::RevokeInvite => GroupNotificationAction::RevokeInvite,
T::GrowthLocked => GroupNotificationAction::GrowthLocked {
expiration: node
.attrs()
.optional_u64("expiration")
.and_then(|t| t.try_into().ok())
.unwrap_or(0),
lock_type: node
.attrs()
.optional_string("type")
.as_deref()
.unwrap_or_default()
.to_string(),
},
T::GrowthUnlocked => GroupNotificationAction::GrowthUnlocked,
T::Create => GroupNotificationAction::Create {
raw: node.to_owned(),
},
T::Delete => GroupNotificationAction::Delete {
reason: node
.attrs()
.optional_string("reason")
.map(|s| s.into_owned()),
},
T::Link => GroupNotificationAction::Link {
link_type: node
.attrs()
.optional_string("link_type")
.as_deref()
.unwrap_or_default()
.to_string(),
raw: node.to_owned(),
},
T::Unlink => GroupNotificationAction::Unlink {
unlink_type: node
.attrs()
.optional_string("unlink_type")
.as_deref()
.unwrap_or_default()
.to_string(),
unlink_reason: node
.attrs()
.optional_string("unlink_reason")
.map(|s| s.into_owned()),
raw: node.to_owned(),
},
T::LinkedGroupPromote => GroupNotificationAction::LinkedGroupPromote {
participants: parse_participants(node),
},
T::LinkedGroupDemote => GroupNotificationAction::LinkedGroupDemote {
participants: parse_participants(node),
},
T::Suspended => GroupNotificationAction::Suspended,
T::Unsuspended => GroupNotificationAction::Unsuspended,
T::AutoAddDisabled => GroupNotificationAction::AutoAddDisabled,
T::IsCapiHostedGroup => GroupNotificationAction::IsCapiHostedGroup,
T::GroupSafetyCheck => GroupNotificationAction::GroupSafetyCheck,
T::LimitSharingEnabled => GroupNotificationAction::LimitSharingEnabled {
trigger: node
.attrs()
.optional_u64("trigger")
.and_then(|t| t.try_into().ok()),
},
T::AllowAdminReports => GroupNotificationAction::AllowAdminReports,
T::NotAllowAdminReports => GroupNotificationAction::NotAllowAdminReports,
T::Reports => GroupNotificationAction::Reports,
T::AllowNonAdminSubGroupCreation => GroupNotificationAction::AllowNonAdminSubGroupCreation,
T::NotAllowNonAdminSubGroupCreation => {
GroupNotificationAction::NotAllowNonAdminSubGroupCreation
}
T::CreatedSubGroupSuggestion => GroupNotificationAction::CreatedSubGroupSuggestion {
raw: node.to_owned(),
},
T::RevokedSubGroupSuggestions => GroupNotificationAction::RevokedSubGroupSuggestions {
raw: node.to_owned(),
},
T::ChangeNumber => GroupNotificationAction::ChangeNumber {
new_owner: node.attrs().optional_jid("jid"),
sub_group_suggestions: parse_sub_group_suggestion_jids(node),
},
T::Unknown(_) => GroupNotificationAction::Unknown {
tag: node.tag.to_string(),
},
};
Some(action)
}
fn parse_participants(node: &NodeRef<'_>) -> Vec<GroupParticipantInfo> {
node.children()
.map(|children| {
children
.iter()
.filter(|c| c.tag == "participant")
.filter_map(|c| {
let mut attrs = c.attrs();
let jid = attrs.optional_jid("jid")?;
let phone_number = attrs.optional_jid("phone_number");
let display_name = attrs
.optional_string("display_name")
.map(|s| s.into_owned());
let r#type = Some(
attrs
.optional_string("type")
.and_then(|s| GroupParticipantType::try_from(s.as_ref()).ok())
.unwrap_or(GroupParticipantType::Participant),
);
let lid = attrs.optional_jid("lid");
let username = attrs
.optional_string("username")
.or_else(|| attrs.optional_string("participant_username"))
.map(|s| s.into_owned());
let join_time = attrs.optional_u64("join_time");
let group_history_sent_state = attrs
.optional_string("group_history_sent_state")
.and_then(|value| GroupHistorySentState::try_from(value.as_ref()).ok());
Some(GroupParticipantInfo {
jid,
phone_number,
display_name,
r#type,
lid,
username,
join_time,
group_history_sent_state,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_requested_users(node: &NodeRef<'_>) -> Vec<GroupParticipantInfo> {
node.children()
.map(|children| {
children
.iter()
.filter(|c| c.tag == "requested_user")
.filter_map(|c| {
let mut attrs = c.attrs();
let jid = attrs.optional_jid("jid")?;
let phone_number = attrs.optional_jid("phone_number");
let username = attrs.optional_string("username").map(|s| s.into_owned());
Some(GroupParticipantInfo {
jid,
phone_number,
display_name: None,
r#type: None,
lid: None,
username,
join_time: None,
group_history_sent_state: None,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_sub_group_suggestion_jids(node: &NodeRef<'_>) -> Vec<Jid> {
node.children()
.map(|children| {
children
.iter()
.filter(|c| c.tag == "sub_group_suggestion")
.filter_map(|c| c.attrs().optional_jid("jid"))
.collect()
})
.unwrap_or_default()
}
fn parse_participant_jids(node: &NodeRef<'_>) -> Vec<Jid> {
node.children()
.map(|children| {
children
.iter()
.filter(|c| c.tag == "participant")
.filter_map(|c| c.attrs().optional_jid("jid"))
.collect()
})
.unwrap_or_default()
}
fn parse_request_method(node: &NodeRef<'_>) -> MembershipRequestMethod {
node.attrs()
.optional_string("request_method")
.as_deref()
.and_then(|s| MembershipRequestMethod::try_from(s).ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use wacore_binary::Jid;
use wacore_binary::builder::NodeBuilder;
fn group_jid() -> Jid {
"120363012345678901@g.us".parse().unwrap()
}
fn user_jid() -> Jid {
"5511999999999@s.whatsapp.net".parse().unwrap()
}
fn admin_jid() -> Jid {
"5511888888888@s.whatsapp.net".parse().unwrap()
}
fn make_notification(children: Vec<Node>) -> Node {
NodeBuilder::new("notification")
.attr("type", "w:gp2")
.attr("from", group_jid())
.attr("participant", admin_jid())
.attr("t", "1704067200")
.children(children)
.build()
}
#[test]
fn test_parse_root_participant_identity_attributes() {
let participant_pn: Jid = "5511888888888@s.whatsapp.net".parse().unwrap();
let node = NodeBuilder::new("notification")
.attr("type", "w:gp2")
.attr("from", group_jid())
.attr("id", "GP-ROOT-1")
.attr("participant", "271060335329480@lid")
.attr("participant_pn", participant_pn.clone())
.attr("participant_username", "group-admin")
.attr("participant_country_code", "BR")
.attr("notify", "Group Admin")
.attr("offline", "1")
.attr("t", "1704067200")
.children(vec![
NodeBuilder::new(MISSING_PARTICIPANT_IDENTIFICATION_TAG).build(),
NodeBuilder::new("announcement").build(),
])
.build();
let notification = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notification.notification_id.as_deref(), Some("GP-ROOT-1"));
assert_eq!(notification.notify.as_deref(), Some("Group Admin"));
assert_eq!(notification.offline.as_deref(), Some("1"));
assert_eq!(notification.participant_pn, Some(participant_pn));
assert_eq!(
notification.participant_username.as_deref(),
Some("group-admin")
);
assert_eq!(notification.participant_country_code.as_deref(), Some("BR"));
assert!(notification.has_incomplete_participant_information);
assert_eq!(notification.actions.len(), 1);
}
#[test]
fn test_parse_add_notification() {
let node = make_notification(vec![
NodeBuilder::new("add")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.group_jid, group_jid());
assert_eq!(notif.participant, Some(admin_jid()));
assert_eq!(notif.timestamp, 1704067200);
assert_eq!(notif.actions.len(), 1);
match ¬if.actions[0] {
GroupNotificationAction::Add {
participants,
reason,
} => {
assert_eq!(participants.len(), 1);
assert_eq!(participants[0].jid, user_jid());
assert!(reason.is_none());
}
other => panic!("expected Add, got {:?}", other),
}
}
#[test]
fn test_parse_modify_carries_display_name() {
let masked =
"+55\u{2219}\u{2219}\u{2219}\u{2219}\u{2219}\u{2219}\u{2219}\u{2219}\u{2219}00";
let node = make_notification(vec![
NodeBuilder::new("modify")
.attr("v_id", "1700000000000001")
.attr("prev_v_id", "1700000000000000")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", "999000000000001@lid")
.attr("display_name", masked)
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Modify { participants } => {
assert_eq!(participants.len(), 1);
assert_eq!(participants[0].display_name.as_deref(), Some(masked));
}
other => panic!("expected Modify, got {:?}", other),
}
}
#[test]
fn test_parse_participant_without_display_name_is_none() {
let node = make_notification(vec![
NodeBuilder::new("add")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Add { participants, .. } => {
assert!(participants[0].display_name.is_none());
}
other => panic!("expected Add, got {:?}", other),
}
}
#[test]
fn test_parse_participant_with_admin_extras() {
let node = make_notification(vec![
NodeBuilder::new("add")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", "55510000001@s.whatsapp.net")
.attr("type", "admin")
.attr("lid", "99900000000001@lid")
.attr("participant_username", "legacy-alice")
.attr("username", "alice")
.attr("join_time", "1700000000")
.attr("group_history_sent_state", "NOTICE_SENT")
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Add { participants, .. } => {
assert_eq!(participants.len(), 1);
let p = &participants[0];
assert_eq!(p.r#type, Some(GroupParticipantType::Admin));
assert_eq!(
p.lid.as_ref().map(|j| j.user.as_str()),
Some("99900000000001")
);
assert_eq!(p.username.as_deref(), Some("alice"));
assert_eq!(p.join_time, Some(1700000000));
assert_eq!(
p.group_history_sent_state,
Some(GroupHistorySentState::NoticeSent)
);
}
other => panic!("expected Add, got {:?}", other),
}
}
#[test]
fn test_participant_missing_type_defaults_to_participant() {
let node = make_notification(vec![
NodeBuilder::new("add")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", "55510000004@s.whatsapp.net")
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Add { participants, .. } => {
assert_eq!(
participants[0].r#type,
Some(GroupParticipantType::Participant)
);
}
other => panic!("expected Add, got {:?}", other),
}
}
#[test]
fn test_participant_type_superadmin_and_unknown_fallback() {
let node = make_notification(vec![
NodeBuilder::new("add")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", "55510000002@s.whatsapp.net")
.attr("type", "superadmin")
.build(),
NodeBuilder::new("participant")
.attr("jid", "55510000003@s.whatsapp.net")
.attr("type", "future_role")
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Add { participants, .. } => {
assert_eq!(
participants[0].r#type,
Some(GroupParticipantType::SuperAdmin)
);
assert_eq!(
participants[1].r#type,
Some(GroupParticipantType::Participant)
);
}
other => panic!("expected Add, got {:?}", other),
}
}
#[test]
fn test_requested_user_does_not_read_admin_extras() {
let node = make_notification(vec![
NodeBuilder::new("created_membership_requests")
.children(vec![
NodeBuilder::new("requested_user")
.attr("jid", "55510000005@s.whatsapp.net")
.attr("type", "admin")
.attr("lid", "99900000000005@lid")
.attr("username", "bob")
.attr("join_time", "1700000005")
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::CreatedMembershipRequests { requests, .. } => {
assert_eq!(requests.len(), 1);
let r = &requests[0];
assert!(
r.r#type.is_none(),
"type must NOT be read on requested_user"
);
assert!(r.lid.is_none());
assert!(r.join_time.is_none());
assert_eq!(r.username.as_deref(), Some("bob"));
}
other => panic!("expected CreatedMembershipRequests, got {:?}", other),
}
}
#[test]
fn test_parse_subject_notification() {
let node = make_notification(vec![
NodeBuilder::new("subject")
.attr("subject", "New Group Name")
.attr("s_o", admin_jid())
.attr("s_t", "1704067200")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.actions.len(), 1);
match ¬if.actions[0] {
GroupNotificationAction::Subject {
subject,
subject_owner,
subject_time,
} => {
assert_eq!(subject, "New Group Name");
assert_eq!(*subject_owner, Some(admin_jid()));
assert_eq!(*subject_time, Some(1704067200));
}
other => panic!("expected Subject, got {:?}", other),
}
}
#[test]
fn test_parse_description_add() {
let node = make_notification(vec![
NodeBuilder::new("description")
.attr("id", "desc123")
.children(vec![
NodeBuilder::new("body")
.string_content("Group description text")
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Description { id, description } => {
assert_eq!(id, "desc123");
assert_eq!(description.as_deref(), Some("Group description text"));
}
other => panic!("expected Description, got {:?}", other),
}
}
#[test]
fn test_parse_description_delete() {
let node = make_notification(vec![
NodeBuilder::new("description")
.attr("id", "desc123")
.children(vec![NodeBuilder::new("delete").build()])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Description { id, description } => {
assert_eq!(id, "desc123");
assert!(description.is_none());
}
other => panic!("expected Description, got {:?}", other),
}
}
#[test]
fn test_parse_settings_notifications() {
let node = make_notification(vec![
NodeBuilder::new("locked").attr("threshold", "100").build(),
NodeBuilder::new("announcement").build(),
NodeBuilder::new("ephemeral")
.attr("expiration", "604800")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.actions.len(), 3);
match ¬if.actions[0] {
GroupNotificationAction::Locked { threshold } => {
assert_eq!(threshold.as_deref(), Some("100"));
}
other => panic!("expected Locked, got {:?}", other),
}
assert!(matches!(
notif.actions[1],
GroupNotificationAction::Announce
));
match ¬if.actions[2] {
GroupNotificationAction::Ephemeral {
expiration,
trigger,
} => {
assert_eq!(*expiration, 604800);
assert!(trigger.is_none());
}
other => panic!("expected Ephemeral, got {:?}", other),
}
}
#[test]
fn test_parse_not_ephemeral() {
let node = make_notification(vec![NodeBuilder::new("not_ephemeral").build()]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Ephemeral {
expiration,
trigger,
} => {
assert_eq!(*expiration, 0);
assert!(trigger.is_none());
}
other => panic!("expected Ephemeral, got {:?}", other),
}
}
#[test]
fn test_parse_membership_approval_mode() {
let node = make_notification(vec![
NodeBuilder::new("membership_approval_mode")
.children(vec![
NodeBuilder::new("group_join").attr("state", "on").build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::MembershipApprovalMode { enabled } => {
assert!(*enabled);
}
other => panic!("expected MembershipApprovalMode, got {:?}", other),
}
}
#[test]
fn test_parse_membership_approval_request() {
let node = make_notification(vec![
NodeBuilder::new("membership_approval_request")
.attr("request_method", "invite_link")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.participant, Some(admin_jid()));
match ¬if.actions[0] {
GroupNotificationAction::MembershipApprovalRequest {
request_method,
parent_group_jid,
} => {
assert_eq!(*request_method, MembershipRequestMethod::InviteLink);
assert!(parent_group_jid.is_none());
}
other => panic!("expected MembershipApprovalRequest, got {:?}", other),
}
}
#[test]
fn test_parse_created_membership_requests() {
let node = make_notification(vec![
NodeBuilder::new("created_membership_requests")
.attr("request_method", "non_admin_add")
.children(vec![
NodeBuilder::new("requested_user")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.participant, Some(admin_jid()));
match ¬if.actions[0] {
GroupNotificationAction::CreatedMembershipRequests {
request_method,
parent_group_jid,
requests,
} => {
assert_eq!(*request_method, MembershipRequestMethod::NonAdminAdd);
assert!(parent_group_jid.is_none());
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].jid, user_jid());
}
other => panic!("expected CreatedMembershipRequests, got {:?}", other),
}
}
#[test]
fn test_parse_revoked_membership_requests() {
let node = make_notification(vec![
NodeBuilder::new("revoked_membership_requests")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.participant, Some(admin_jid()));
match ¬if.actions[0] {
GroupNotificationAction::RevokedMembershipRequests { participants } => {
assert_eq!(participants.len(), 1);
assert_eq!(participants[0], user_jid());
}
other => panic!("expected RevokedMembershipRequests, got {:?}", other),
}
}
#[test]
fn test_parse_membership_approval_request_default_method() {
let node = make_notification(vec![
NodeBuilder::new("membership_approval_request").build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert_eq!(notif.participant, Some(admin_jid()));
match ¬if.actions[0] {
GroupNotificationAction::MembershipApprovalRequest {
request_method,
parent_group_jid,
} => {
assert_eq!(*request_method, MembershipRequestMethod::InviteLink);
assert!(parent_group_jid.is_none());
}
other => panic!("expected MembershipApprovalRequest, got {:?}", other),
}
}
#[test]
fn test_parse_membership_request_with_parent_group_jid() {
let parent_jid: Jid = "999999999999999999@g.us".parse().unwrap();
let approval_node = make_notification(vec![
NodeBuilder::new("membership_approval_request")
.attr("request_method", "linked_group_join")
.attr("parent_group_jid", parent_jid.clone())
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&approval_node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::MembershipApprovalRequest {
request_method,
parent_group_jid,
} => {
assert_eq!(*request_method, MembershipRequestMethod::LinkedGroupJoin);
assert_eq!(*parent_group_jid, Some(parent_jid.clone()));
}
other => panic!("expected MembershipApprovalRequest, got {:?}", other),
}
let created_node = make_notification(vec![
NodeBuilder::new("created_membership_requests")
.attr("request_method", "linked_group_join")
.attr("parent_group_jid", parent_jid.clone())
.children(vec![
NodeBuilder::new("requested_user")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif2 = GroupNotification::try_from_node_ref(&created_node.as_node_ref()).unwrap();
match ¬if2.actions[0] {
GroupNotificationAction::CreatedMembershipRequests {
request_method,
parent_group_jid,
requests,
} => {
assert_eq!(*request_method, MembershipRequestMethod::LinkedGroupJoin);
assert_eq!(*parent_group_jid, Some(parent_jid));
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].jid, user_jid());
}
other => panic!("expected CreatedMembershipRequests, got {:?}", other),
}
}
#[test]
fn test_parse_linked_group_promote_demote_carry_participants() {
let promote_node = make_notification(vec![
NodeBuilder::new("linked_group_promote")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&promote_node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::LinkedGroupPromote { participants } => {
assert_eq!(participants.len(), 1);
assert_eq!(participants[0].jid, user_jid());
}
other => panic!("expected LinkedGroupPromote, got {:?}", other),
}
let demote_node = make_notification(vec![
NodeBuilder::new("linked_group_demote")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&demote_node.as_node_ref()).unwrap();
assert!(matches!(
notif.actions[0],
GroupNotificationAction::LinkedGroupDemote { .. }
));
}
#[test]
fn test_parse_suspended_toggle_variants() {
type Matcher = fn(&GroupNotificationAction) -> bool;
let table: &[(&str, Matcher)] = &[
("suspended", |a| {
matches!(a, GroupNotificationAction::Suspended)
}),
("unsuspended", |a| {
matches!(a, GroupNotificationAction::Unsuspended)
}),
("auto_add_disabled", |a| {
matches!(a, GroupNotificationAction::AutoAddDisabled)
}),
("is_capi_hosted_group", |a| {
matches!(a, GroupNotificationAction::IsCapiHostedGroup)
}),
("group_safety_check", |a| {
matches!(a, GroupNotificationAction::GroupSafetyCheck)
}),
("limit_sharing_enabled", |a| {
matches!(a, GroupNotificationAction::LimitSharingEnabled { .. })
}),
("allow_admin_reports", |a| {
matches!(a, GroupNotificationAction::AllowAdminReports)
}),
("not_allow_admin_reports", |a| {
matches!(a, GroupNotificationAction::NotAllowAdminReports)
}),
("reports", |a| matches!(a, GroupNotificationAction::Reports)),
("allow_non_admin_sub_group_creation", |a| {
matches!(a, GroupNotificationAction::AllowNonAdminSubGroupCreation)
}),
("not_allow_non_admin_sub_group_creation", |a| {
matches!(a, GroupNotificationAction::NotAllowNonAdminSubGroupCreation)
}),
];
for (tag, matcher) in table {
let node = make_notification(vec![NodeBuilder::new(tag).build()]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
assert!(
matcher(¬if.actions[0]),
"wire tag {tag} parsed to unexpected variant: {:?}",
notif.actions[0]
);
}
}
#[test]
fn test_parse_limit_sharing_enabled_captures_trigger() {
let with_trigger = make_notification(vec![
NodeBuilder::new("limit_sharing_enabled")
.attr("trigger", "5")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&with_trigger.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::LimitSharingEnabled { trigger } => {
assert_eq!(*trigger, Some(5));
}
other => panic!("expected LimitSharingEnabled, got {:?}", other),
}
let no_trigger = make_notification(vec![NodeBuilder::new("limit_sharing_enabled").build()]);
let notif = GroupNotification::try_from_node_ref(&no_trigger.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::LimitSharingEnabled { trigger } => assert!(trigger.is_none()),
other => panic!("expected LimitSharingEnabled, got {:?}", other),
}
}
#[test]
fn test_parse_limit_sharing_enabled_overflow_yields_none() {
let node = make_notification(vec![
NodeBuilder::new("limit_sharing_enabled")
.attr("trigger", "4294967296")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::LimitSharingEnabled { trigger } => assert!(trigger.is_none()),
other => panic!("expected LimitSharingEnabled, got {:?}", other),
}
}
#[test]
fn test_parse_ephemeral_overflow_yields_zero_and_none() {
let node = make_notification(vec![
NodeBuilder::new("ephemeral")
.attr("expiration", "4294967296")
.attr("trigger", "4294967296")
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Ephemeral {
expiration,
trigger,
} => {
assert_eq!(*expiration, 0);
assert!(trigger.is_none());
}
other => panic!("expected Ephemeral, got {:?}", other),
}
}
#[test]
fn test_parse_change_number_real_shape() {
let new_owner_jid = "5511888888888@s.whatsapp.net";
let sg_a = "111111111111111@g.us";
let sg_b = "222222222222222@g.us";
let node = make_notification(vec![
NodeBuilder::new("change_number")
.attr("jid", new_owner_jid)
.children(vec![
NodeBuilder::new("sub_group_suggestion")
.attr("jid", sg_a)
.build(),
NodeBuilder::new("sub_group_suggestion")
.attr("jid", sg_b)
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::ChangeNumber {
new_owner,
sub_group_suggestions,
} => {
assert_eq!(
new_owner.as_ref().map(|j| j.to_string()).as_deref(),
Some(new_owner_jid)
);
assert_eq!(sub_group_suggestions.len(), 2);
assert_eq!(sub_group_suggestions[0].to_string(), sg_a);
assert_eq!(sub_group_suggestions[1].to_string(), sg_b);
}
other => panic!("expected ChangeNumber, got {:?}", other),
}
}
#[test]
fn test_parse_change_number_missing_jid_is_tolerant() {
let node = make_notification(vec![NodeBuilder::new("change_number").build()]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::ChangeNumber {
new_owner,
sub_group_suggestions,
} => {
assert!(new_owner.is_none());
assert!(sub_group_suggestions.is_empty());
}
other => panic!("expected ChangeNumber, got {:?}", other),
}
}
#[test]
fn test_parse_change_number_ignores_participant_children() {
let node = make_notification(vec![
NodeBuilder::new("change_number")
.attr("jid", "5511888888888@s.whatsapp.net")
.children(vec![
NodeBuilder::new("participant")
.attr("jid", user_jid())
.build(),
])
.build(),
]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::ChangeNumber {
sub_group_suggestions,
..
} => {
assert!(sub_group_suggestions.is_empty());
}
other => panic!("expected ChangeNumber, got {:?}", other),
}
}
#[test]
fn test_parse_unknown_tag() {
let node = make_notification(vec![NodeBuilder::new("some_future_feature").build()]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap();
match ¬if.actions[0] {
GroupNotificationAction::Unknown { tag } => {
assert_eq!(tag, "some_future_feature");
}
other => panic!("expected Unknown, got {:?}", other),
}
}
#[test]
fn test_missing_from_returns_none() {
let node = NodeBuilder::new("notification")
.attr("type", "w:gp2")
.attr("t", "1704067200")
.build();
assert!(GroupNotification::try_from_node_ref(&node.as_node_ref()).is_none());
}
#[test]
fn serialize_discriminator_matches_wire_tag() {
let dummy_node = NodeBuilder::new("placeholder").build();
let samples: Vec<GroupNotificationAction> = vec![
GroupNotificationAction::Add {
participants: vec![],
reason: None,
},
GroupNotificationAction::Remove {
participants: vec![],
reason: Some("r".into()),
},
GroupNotificationAction::Promote {
participants: vec![],
},
GroupNotificationAction::Demote {
participants: vec![],
},
GroupNotificationAction::Modify {
participants: vec![],
},
GroupNotificationAction::Subject {
subject: "s".into(),
subject_owner: None,
subject_time: None,
},
GroupNotificationAction::Description {
id: "i".into(),
description: None,
},
GroupNotificationAction::Locked { threshold: None },
GroupNotificationAction::Unlocked,
GroupNotificationAction::Announce,
GroupNotificationAction::NotAnnounce,
GroupNotificationAction::Ephemeral {
expiration: 0,
trigger: None,
},
GroupNotificationAction::MembershipApprovalMode { enabled: true },
GroupNotificationAction::MembershipApprovalRequest {
request_method: MembershipRequestMethod::InviteLink,
parent_group_jid: None,
},
GroupNotificationAction::CreatedMembershipRequests {
request_method: MembershipRequestMethod::InviteLink,
parent_group_jid: None,
requests: vec![],
},
GroupNotificationAction::RevokedMembershipRequests {
participants: vec![],
},
GroupNotificationAction::MemberAddMode { mode: "x".into() },
GroupNotificationAction::NoFrequentlyForwarded,
GroupNotificationAction::FrequentlyForwardedOk,
GroupNotificationAction::Invite { code: "c".into() },
GroupNotificationAction::RevokeInvite,
GroupNotificationAction::GrowthLocked {
expiration: 0,
lock_type: "x".into(),
},
GroupNotificationAction::GrowthUnlocked,
GroupNotificationAction::Create {
raw: dummy_node.clone(),
},
GroupNotificationAction::Delete { reason: None },
GroupNotificationAction::Link {
link_type: "x".into(),
raw: dummy_node.clone(),
},
GroupNotificationAction::Unlink {
unlink_type: "x".into(),
unlink_reason: None,
raw: dummy_node.clone(),
},
GroupNotificationAction::LinkedGroupPromote {
participants: vec![],
},
GroupNotificationAction::LinkedGroupDemote {
participants: vec![],
},
GroupNotificationAction::Suspended,
GroupNotificationAction::Unsuspended,
GroupNotificationAction::AutoAddDisabled,
GroupNotificationAction::IsCapiHostedGroup,
GroupNotificationAction::GroupSafetyCheck,
GroupNotificationAction::LimitSharingEnabled { trigger: None },
GroupNotificationAction::AllowAdminReports,
GroupNotificationAction::NotAllowAdminReports,
GroupNotificationAction::Reports,
GroupNotificationAction::AllowNonAdminSubGroupCreation,
GroupNotificationAction::NotAllowNonAdminSubGroupCreation,
GroupNotificationAction::CreatedSubGroupSuggestion {
raw: dummy_node.clone(),
},
GroupNotificationAction::RevokedSubGroupSuggestions {
raw: dummy_node.clone(),
},
GroupNotificationAction::ChangeNumber {
new_owner: None,
sub_group_suggestions: vec![],
},
GroupNotificationAction::Unknown {
tag: "future_tag".into(),
},
];
let _ = dummy_node;
for action in &samples {
let value = serde_json::to_value(action).expect("serialize");
let ty = value
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("missing type in {value}"));
assert_eq!(
ty,
action.tag_name(),
"serialized discriminator diverged from wire tag for {action:?}"
);
}
}
#[test]
fn wire_tags_round_trip_through_parser() {
type Check = fn(&GroupNotificationAction) -> bool;
let cases: &[(&str, Check)] = &[
("add", |a| matches!(a, GroupNotificationAction::Add { .. })),
("demote", |a| {
matches!(a, GroupNotificationAction::Demote { .. })
}),
("promote", |a| {
matches!(a, GroupNotificationAction::Promote { .. })
}),
("revoke", |a| {
matches!(a, GroupNotificationAction::RevokeInvite)
}),
("not_announcement", |a| {
matches!(a, GroupNotificationAction::NotAnnounce)
}),
("announcement", |a| {
matches!(a, GroupNotificationAction::Announce)
}),
];
for (tag, check) in cases {
let node = make_notification(vec![NodeBuilder::new(tag).build()]);
let notif = GroupNotification::try_from_node_ref(&node.as_node_ref())
.unwrap_or_else(|| panic!("parse failed for <{tag}>"));
let action = ¬if.actions[0];
assert!(
check(action),
"tag <{tag}> did not produce expected variant (got {action:?})"
);
assert_eq!(action.tag_name(), *tag);
}
}
}