diff --git a/src/client.rs b/src/client.rs
index 69b59ef82..9083f5516 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -527,6 +527,19 @@ impl Client {
self.shutdown_notifier.subscribe()
}
+ /// Synchronous flag-only equivalent of the first lines of `disconnect()`.
+ /// Spawned tasks watching `is_shutting_down()` / `shutdown_notifier` exit
+ /// on their next poll. Does NOT flush, close the transport, or touch
+ /// persistence — prefer `disconnect()` whenever you can `await`. Exists
+ /// for `Drop` impls on FFI wrappers (e.g. `WasmWhatsAppClient`) that
+ /// can't run async cleanup synchronously.
+ pub fn signal_shutdown_sync(&self) {
+ self.expected_disconnect.store(true, Ordering::Relaxed);
+ self.is_running.store(false, Ordering::Relaxed);
+ self.shutdown_notifier.notify();
+ self.notify_connection_shutdown();
+ }
+
pub(crate) fn connection_shutdown_signal(&self) -> wacore::runtime::ShutdownSignal {
self.connection_shutdown
.lock()
@@ -1829,12 +1842,26 @@ impl Client {
}
/// Determine if a node should be acknowledged with .
+ ///
+ /// Newsletter messages need `` (per
+ /// `OutMessageDeliverCommonAckMixin`); regular DM/group send a
+ /// `` instead. Status broadcasts also need the ack until
+ /// `send_delivery_receipt` stops skipping them.
fn should_ack(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
- matches!(
- node.tag.as_ref(),
- "message" | "receipt" | "notification" | "call"
- ) && node.get_attr("id").is_some()
- && node.get_attr("from").is_some()
+ let tag = node.tag.as_ref();
+ if node.get_attr("id").is_none() {
+ return false;
+ }
+ let Some(from) = node.get_attr("from") else {
+ return false;
+ };
+ match tag {
+ "receipt" | "notification" | "call" => true,
+ "message" => from
+ .to_jid()
+ .is_some_and(|j| j.is_newsletter() || j.is_status_broadcast()),
+ _ => false,
+ }
}
/// Possibly send a deferred ack: either immediately or via spawned task.
@@ -3833,7 +3860,13 @@ fn encode_ack_bytes(
let Some(from_val) = node.get_attr("from") else {
return Ok(None);
};
- let participant_val = node.get_attr("participant");
+ // WAWebReceiptAck: `participant: r && r !== e ? DEVICE_JID(r) : DROP_ATTR`.
+ // Drop the attribute when it would duplicate `to` (which is the flipped `from`).
+ let participant_val = node.get_attr("participant").filter(|p| {
+ let p_str = p.as_str();
+ let from_str = from_val.as_str();
+ p_str.as_ref() != from_str.as_ref()
+ });
let tag = node.tag.as_ref();
let typ_val = if tag != "message" && !is_encrypt_identity_notification(node) {
@@ -3922,8 +3955,13 @@ fn encode_ack_bytes(
#[cfg(test)]
fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>) -> Option {
let id = node.get_attr("id")?.to_node_value();
- let from = node.get_attr("from")?.to_node_value();
- let participant = node.get_attr("participant").map(|v| v.to_node_value());
+ let from_ref = node.get_attr("from")?;
+ let from = from_ref.to_node_value();
+ // Drop participant when it duplicates `to` (the flipped `from`).
+ let participant = node
+ .get_attr("participant")
+ .filter(|p| p.as_str().as_ref() != from_ref.as_str().as_ref())
+ .map(|v| v.to_node_value());
let tag = node.tag.as_ref();
let typ = if tag != "message" && !is_encrypt_identity_notification(node) {
node.get_attr("type").map(|v| v.to_node_value())
@@ -4047,6 +4085,53 @@ mod tests {
"should_ack must still return TRUE for stanzas."
);
+ // Regular stanzas (DM / group) are acked via the delivery
+ // , not a bare . WA Web only emits
+ // for newsletter deliveries.
+ let mut dm_attrs = Attrs::new();
+ dm_attrs.insert(
+ "from".to_string(),
+ "5511999999999@s.whatsapp.net".to_string(),
+ );
+ dm_attrs.insert("id".to_string(), "MSG-DM-1".to_string());
+ let dm_message = Node::new("message", dm_attrs, None);
+ assert!(
+ !client.should_ack(&dm_message.as_node_ref()),
+ "should_ack must return FALSE for regular DM (delivery receipt covers it)."
+ );
+
+ let mut group_attrs = Attrs::new();
+ group_attrs.insert("from".to_string(), "120363098765432100@g.us".to_string());
+ group_attrs.insert("id".to_string(), "MSG-GROUP-1".to_string());
+ let group_message = Node::new("message", group_attrs, None);
+ assert!(
+ !client.should_ack(&group_message.as_node_ref()),
+ "should_ack must return FALSE for group ."
+ );
+
+ let mut newsletter_attrs = Attrs::new();
+ newsletter_attrs.insert(
+ "from".to_string(),
+ "120363298765432100@newsletter".to_string(),
+ );
+ newsletter_attrs.insert("id".to_string(), "MSG-NL-1".to_string());
+ let newsletter_message = Node::new("message", newsletter_attrs, None);
+ assert!(
+ client.should_ack(&newsletter_message.as_node_ref()),
+ "should_ack must return TRUE for newsletter ."
+ );
+
+ // send_delivery_receipt skips status@broadcast, so the ack stays
+ // as the server-level acknowledgement until receipts cover it.
+ let mut status_attrs = Attrs::new();
+ status_attrs.insert("from".to_string(), "status@broadcast".to_string());
+ status_attrs.insert("id".to_string(), "MSG-STATUS-1".to_string());
+ let status_message = Node::new("message", status_attrs, None);
+ assert!(
+ client.should_ack(&status_message.as_node_ref()),
+ "should_ack must return TRUE for status@broadcast until receipts cover it."
+ );
+
info!(
"✅ test_ack_behavior_for_incoming_stanzas passed: Client correctly differentiates which stanzas to acknowledge."
);
@@ -5673,6 +5758,47 @@ mod tests {
);
}
+ #[test]
+ fn test_build_ack_node_drops_participant_when_equal_to_from() {
+ // WAWebReceiptAck: `participant: r && r !== e ? DEVICE_JID(r) : DROP_ATTR`.
+ // When the incoming stanza carries participant == from (redundant),
+ // the ack must not echo it.
+ let incoming = NodeBuilder::new("receipt")
+ .attr("from", "156535032389744@lid")
+ .attr("participant", "156535032389744@lid")
+ .attr("id", "RCPT-PARTICIPANT-EQ-FROM")
+ .build();
+ let own_device_pn: Jid = "155500012345:48@s.whatsapp.net".parse().unwrap();
+
+ let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
+ .expect("ack should build");
+ assert!(
+ !ack.attrs.contains_key("participant"),
+ "ack must drop participant when it duplicates `to` (the flipped from); got {:?}",
+ ack.attrs.get("participant")
+ );
+ }
+
+ #[test]
+ fn test_build_ack_node_keeps_participant_when_distinct_from_from() {
+ // Group receipt: participant = sender (user), from = group jid; must be kept.
+ let incoming = NodeBuilder::new("receipt")
+ .attr("from", "120363098765432100@g.us")
+ .attr("participant", "5511999999999@s.whatsapp.net")
+ .attr("id", "RCPT-GROUP")
+ .build();
+ let own_device_pn: Jid = "155500012345:48@s.whatsapp.net".parse().unwrap();
+
+ let ack = build_ack_node(&incoming.as_node_ref(), Some(&own_device_pn))
+ .expect("ack should build");
+ assert!(
+ ack.attrs
+ .get("participant")
+ .is_some_and(|v| v == "5511999999999@s.whatsapp.net"),
+ "ack must keep participant when it differs from `to`"
+ );
+ }
+
#[test]
fn test_build_ack_node_for_receipt_without_type_omits_type() {
// Delivery receipts have no type attribute — the ack must also omit it.
diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs
index 1d5c73833..b5d439665 100644
--- a/wacore/src/iq/contacts.rs
+++ b/wacore/src/iq/contacts.rs
@@ -195,10 +195,9 @@ pub struct SetProfilePictureResponse {
///
/// ## Wire Format (Remove)
/// ```xml
-///
-///
-///
+///
/// ```
+/// No `` child: `WAWebSendProfilePictureJob` emits an empty IQ.
///
/// ## Response
/// ```xml
@@ -260,17 +259,18 @@ impl IqSpec for SetProfilePictureSpec {
type Response = SetProfilePictureResponse;
fn build_iq(&self) -> InfoQuery<'static> {
- let mut picture_builder = NodeBuilder::new("picture").attr("type", "image");
-
- if let Some(data) = &self.image_data {
- picture_builder = picture_builder.bytes(data.clone());
- }
-
- let mut iq = InfoQuery::set(
- "w:profile:picture",
- Jid::new("", Server::Pn),
- Some(NodeContent::Nodes(vec![picture_builder.build()])),
- );
+ // WAWebSendProfilePictureJob: emits `{bytes}`
+ // on set, NO `` child on remove.
+ let content = self.image_data.as_ref().map(|data| {
+ NodeContent::Nodes(vec![
+ NodeBuilder::new("picture")
+ .attr("type", "image")
+ .bytes(data.clone())
+ .build(),
+ ])
+ });
+
+ let mut iq = InfoQuery::set("w:profile:picture", Jid::new("", Server::Pn), content);
if let Some(target) = &self.target {
iq = iq.with_target_ref(target);
@@ -466,20 +466,24 @@ mod tests {
}
#[test]
- fn test_set_profile_picture_spec_remove_own() {
+ fn test_set_profile_picture_spec_remove_own_emits_no_picture_child() {
+ // WAWebSendProfilePictureJob: removal IQ has no `` child at all.
let spec = SetProfilePictureSpec::remove_own();
let iq = spec.build_iq();
+ assert!(
+ iq.content.is_none(),
+ "Remove must emit an empty with no child; got {:?}",
+ iq.content
+ );
+ }
- if let Some(NodeContent::Nodes(nodes)) = &iq.content {
- let picture = &nodes[0];
- // Remove: picture node with no content
- assert!(
- picture.content.is_none(),
- "Remove should have no picture content"
- );
- } else {
- panic!("Expected NodeContent::Nodes");
- }
+ #[test]
+ fn test_set_profile_picture_spec_remove_group_emits_no_picture_child() {
+ let group_jid: Jid = "123456789@g.us".parse().unwrap();
+ let spec = SetProfilePictureSpec::remove_group(&group_jid);
+ let iq = spec.build_iq();
+ assert!(iq.content.is_none(), "Remove group: no child");
+ assert_eq!(iq.target, Some(group_jid));
}
#[test]
diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs
index 70bbe87b0..4b5ffb64e 100644
--- a/wacore/src/iq/groups.rs
+++ b/wacore/src/iq/groups.rs
@@ -267,6 +267,15 @@ pub struct GroupCreateOptions {
/// Only used when `is_parent` is true.
#[builder(default)]
pub create_general_chat: bool,
+ /// Parent community to link this subgroup to. Atomic alternative to
+ /// creating then linking; mutually exclusive with `is_parent`.
+ #[builder(default, setter(strip_option, into))]
+ pub linked_parent: Option,
+ /// Inline description carried on the create stanza; avoids a follow-up
+ /// SetGroupDescription IQ. Validation (length cap) goes through
+ /// [`GroupDescription`] so both create paths share the same contract.
+ #[builder(default, setter(strip_option, into))]
+ pub description: Option,
}
impl GroupCreateOptions {
@@ -322,11 +331,24 @@ impl Default for GroupCreateOptions {
closed: false,
allow_non_admin_sub_group_creation: false,
create_general_chat: false,
+ linked_parent: None,
+ description: None,
}
}
}
/// Normalize participants: drop phone_number for non-LID JIDs.
+/// Random 8-char hex token for a `` attribute. Shared
+/// between create-with-inline-description and SetGroupDescriptionIq so the
+/// RNG seeding stays in one place.
+fn generate_description_id() -> String {
+ use rand::RngExt as _;
+ format!(
+ "{:08X}",
+ rand::make_rng::().random::()
+ )
+}
+
pub fn normalize_participants(
participants: &[GroupParticipantOptions],
) -> Vec {
@@ -406,8 +428,30 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
);
}
- // Community (parent group) fields
- if options.is_parent {
+ // `` (this group IS a community) and `` (this
+ // group is a subgroup of X) are mutually exclusive. When both are
+ // requested, `linked_parent` wins; it carries an explicit target.
+ 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 =
@@ -423,6 +467,18 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node {
}
}
+ // Inline description: WA Web emits `{text}`.
+ 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)
@@ -1047,14 +1103,13 @@ impl IqSpec for GroupCreateIq {
let group_node = required_child(response, "group")?;
let mut info = GroupInfoResponse::try_from_node_ref(group_node)?;
- // The server may omit `` / ``
- // from the create reply (WA Web's CreateJob never reads them either),
- // so propagate the request flags so a freshly created community classifies
- // correctly via `group_type()` without a follow-up metadata query.
- // `allow_non_admin_sub_group_creation` is one-directional (request only
- // fills when server omitted) so a server-set `true` is never clobbered
- // by a `false` request flag.
- if self.options.is_parent {
+ // Server may omit `` from a community-create reply; overlay
+ // request flags so `group_type()` classifies without a follow-up query.
+ // A `linked_parent` (in request or response) means this is a subgroup,
+ // so don't promote it to parent even if `is_parent` was requested.
+ 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.allow_non_admin_sub_group_creation |=
self.options.allow_non_admin_sub_group_creation;
@@ -1068,22 +1123,26 @@ impl IqSpec for GroupCreateIq {
// Group Management IQ Specs
// ---------------------------------------------------------------------------
-/// Response for participant change operations.
-///
-/// Success is signaled by absent `error`; `type` is often omitted by the server.
-#[derive(Debug, Clone, crate::ProtocolNode)]
-#[protocol(tag = "participant")]
+/// V4 invite token returned in `` when privacy
+/// blocks a direct add; lets callers fall back to a `GroupInviteMessage`.
+/// Wire: ``.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AddRequestInfo {
+ pub code: String,
+ pub expiration: u64,
+}
+
+/// Response for participant change operations. Success: `error` is None;
+/// `type` is often omitted by the server. On `error == "403"` the
+/// `` child (`add_request` field) carries the V4 invite token.
+#[derive(Debug, Clone)]
pub struct ParticipantChangeResponse {
- #[attr(name = "jid", jid)]
pub jid: Jid,
- #[attr(name = "type")]
pub status: Option,
- #[attr(name = "error")]
pub error: Option,
- #[attr(name = "phone_number", jid)]
pub phone_number: Option,
- #[attr(name = "username")]
pub username: Option,
+ pub add_request: Option,
}
impl ParticipantChangeResponse {
@@ -1092,6 +1151,87 @@ impl ParticipantChangeResponse {
}
}
+impl crate::protocol::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 {
+ if node.tag != "participant" {
+ return Err(::anyhow::anyhow!(
+ "expected , 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());
+
+ // Absent → None. Present but malformed → hard error so a server-side
+ // drop of the V4 invite token doesn't silently disappear.
+ let add_request = node
+ .get_optional_child("add_request")
+ .map(|n| -> ::anyhow::Result {
+ let mut a = n.attrs();
+ let code = a
+ .optional_string("code")
+ .ok_or_else(|| {
+ ::anyhow::anyhow!(" missing required 'code' attribute")
+ })?
+ .into_owned();
+ let expiration = a
+ .optional_string("expiration")
+ .ok_or_else(|| {
+ ::anyhow::anyhow!(" missing required 'expiration' attribute")
+ })?
+ .parse::()
+ .map_err(|e| {
+ ::anyhow::anyhow!(" 'expiration' is not a u64: {e}")
+ })?;
+ Ok(AddRequestInfo { code, expiration })
+ })
+ .transpose()?;
+
+ Ok(Self {
+ jid,
+ status,
+ error,
+ phone_number,
+ username,
+ add_request,
+ })
+ }
+}
+
/// IQ specification for setting a group's subject.
///
/// Wire format:
@@ -1163,11 +1303,7 @@ impl SetGroupDescriptionIq {
description: Option,
prev: Option,
) -> Self {
- use rand::RngExt;
- let id = format!(
- "{:08X}",
- rand::make_rng::().random::()
- );
+ let id = generate_description_id();
Self {
group_jid: group_jid.clone(),
description,
@@ -1616,6 +1752,9 @@ impl IqSpec for SetGroupAnnouncementIq {
}
}
+/// Max for ``, per `WASmaxInGroupsGroupInfoMixin`.
+pub const EPHEMERAL_TRIGGER_MAX: u32 = 20;
+
/// IQ specification for setting ephemeral (disappearing) messages on a group.
///
/// Wire format:
@@ -1628,6 +1767,7 @@ impl IqSpec for SetGroupAnnouncementIq {
/// ```
///
/// Common expiration values (seconds):
+///
/// - 86400 (24 hours)
/// - 604800 (7 days)
/// - 7776000 (90 days)
@@ -1637,6 +1777,9 @@ pub struct SetGroupEphemeralIq {
pub group_jid: Jid,
/// Expiration in seconds. `None` means disable.
pub expiration: Option,
+ /// `trigger` attr on `` (0..=[`EPHEMERAL_TRIGGER_MAX`]);
+ /// identifies the disappearing-mode source. `None` omits the attr.
+ pub trigger: Option,
}
impl SetGroupEphemeralIq {
@@ -1645,6 +1788,23 @@ impl SetGroupEphemeralIq {
Self {
group_jid: group_jid.clone(),
expiration: Some(expiration),
+ trigger: None,
+ }
+ }
+
+ /// Enable ephemeral messages with an explicit `trigger`.
+ ///
+ /// # Panics
+ /// If `trigger > EPHEMERAL_TRIGGER_MAX`.
+ 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),
}
}
@@ -1653,6 +1813,7 @@ impl SetGroupEphemeralIq {
Self {
group_jid: group_jid.clone(),
expiration: None,
+ trigger: None,
}
}
}
@@ -1662,9 +1823,18 @@ impl IqSpec for SetGroupEphemeralIq {
fn build_iq(&self) -> InfoQuery<'static> {
let node = match self.expiration {
- Some(exp) => NodeBuilder::new("ephemeral")
- .attr("expiration", exp.get())
- .build(),
+ Some(exp) => {
+ let mut b = NodeBuilder::new("ephemeral").attr("expiration", exp.get());
+ // Skip out-of-range triggers instead of emitting them; the
+ // constructor asserts on misuse, this is the defence-in-depth
+ // path for direct field assignment.
+ 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(
@@ -3143,6 +3313,73 @@ mod tests {
assert_eq!(result.username.as_deref(), Some("example_user"));
}
+ #[test]
+ fn test_participant_change_response_parses_add_request_on_403() {
+ // WAWebInGroupsParticipantRequestCodeCanBeSentMixin: on error="403"
+ // the server returns the V4 invite token in .
+ 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() {
+ // present but no code → hard error.
+ 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'"));
+
+ // with non-numeric expiration → hard error.
+ 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();
@@ -3212,6 +3449,75 @@ mod tests {
}
}
+ #[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();
+ // Boundary: trigger == EPHEMERAL_TRIGGER_MAX must succeed.
+ 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();
+ // Bypass the constructor and write directly to mimic a caller that
+ // sets the public field to an invalid value.
+ 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();
@@ -3590,6 +3896,132 @@ mod tests {
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 ");
+ };
+ 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() {
+ // Setting both is a programmer error: debug builds panic on the
+ // debug_assert; release builds silently emit only .
+ 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 ");
+ };
+ 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 ");
+ };
+ 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 ");
+ };
+ assert!(nodes[0].get_optional_child("linked_parent").is_none());
+ assert!(nodes[0].get_optional_child("description").is_none());
+ }
+
/// Plain (non-community) group create: overlay branch must not run, both
/// flags stay at the parsed defaults (`false`).
#[test]
diff --git a/wacore/src/proto_helpers.rs b/wacore/src/proto_helpers.rs
index a483ef0eb..548a3b0e7 100644
--- a/wacore/src/proto_helpers.rs
+++ b/wacore/src/proto_helpers.rs
@@ -2,12 +2,10 @@ use std::str::FromStr;
use wacore_binary::{Jid, JidExt};
use waproto::whatsapp as wa;
-/// Invokes a callback macro with the list of all message types that have `context_info`.
-///
-/// This macro ensures both `for_each_context_info_message!` and `set_context_info_on_message!`
-/// use the same list of message types, making it easy to add new types in one place.
-///
-/// When WhatsApp adds new message types with context_info, add them here.
+/// Single source of truth for the message types that carry a `context_info`
+/// field. Consumed by `for_each_context_info_message!`, `set_context_info`
+/// (via an inlined `try_attach!`), `get_ephemeral_expiration`, and
+/// `set_ephemeral_expiration`. Add new WA message types with context_info here.
macro_rules! with_context_info_fields {
($callback:ident!($($prefix:tt)*)) => {
$callback!($($prefix)*
@@ -66,27 +64,6 @@ macro_rules! for_each_context_info_impl {
};
}
-/// Sets context_info on the first matching message type.
-/// Returns true if context was set, false otherwise.
-macro_rules! set_context_info_on_message {
- ($msg:expr, $ctx:expr) => {
- with_context_info_fields!(set_context_info_impl!($msg, $ctx,))
- };
-}
-
-macro_rules! set_context_info_impl {
- ($msg:expr, $ctx:expr, $($field:ident),+ $(,)?) => {{
- let ctx = $ctx;
- $(
- if let Some(ref mut m) = $msg.$field {
- m.context_info = Some(ctx);
- return true;
- }
- )+
- false
- }};
-}
-
/// Extension trait for wa::Message
pub trait MessageExt {
/// Recursively unwraps ephemeral/view-once/document_with_caption/edited wrappers to get the core message.
@@ -153,9 +130,11 @@ pub trait MessageExt {
/// Reads `context_info.expiration` from the first message type that has it.
fn get_ephemeral_expiration(&self) -> Option;
- /// Sets `context_info.expiration` on the first message type found.
- /// Creates a default `context_info` if needed. Returns `false` for
- /// bare `conversation` messages (use `ExtendedTextMessage` instead).
+ /// Sets `context_info.expiration` on the first message type found, creating
+ /// a default `context_info` if needed. A bare `conversation` body is
+ /// promoted to `extended_text_message { text, context_info { expiration } }`
+ /// (mirrors `WAWebMessageSendUtils`). Returns `true` on success or
+ /// promotion, `false` only when no body can carry the timer.
fn set_ephemeral_expiration(&mut self, expiration: u32) -> bool;
}
@@ -315,7 +294,29 @@ impl MessageExt for wa::Message {
}
fn set_context_info(&mut self, context: wa::ContextInfo) -> bool {
- set_context_info_on_message!(self, Box::new(context))
+ macro_rules! try_attach {
+ ($($field:ident),+ $(,)?) => {
+ $(
+ if let Some(ref mut m) = self.$field {
+ m.context_info = Some(Box::new(context));
+ return true;
+ }
+ )+
+ };
+ }
+ with_context_info_fields!(try_attach!());
+
+ // Promote bare conversation to extended_text_message so the context
+ // can attach; matches WAWebMessageSendUtils.
+ if let Some(text) = self.conversation.take() {
+ self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage {
+ text: Some(text),
+ context_info: Some(Box::new(context)),
+ ..Default::default()
+ }));
+ return true;
+ }
+ false
}
fn get_ephemeral_expiration(&self) -> Option {
@@ -354,6 +355,21 @@ impl MessageExt for wa::Message {
};
}
with_context_info_fields!(try_set!());
+
+ // Promote bare conversation so the timer can attach; matches
+ // WAWebMessageSendUtils.
+ if let Some(text) = self.conversation.take() {
+ self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage {
+ text: Some(text),
+ context_info: Some(Box::new(wa::ContextInfo {
+ expiration: Some(expiration),
+ ..Default::default()
+ })),
+ ..Default::default()
+ }));
+ return true;
+ }
+
false
}
}
@@ -897,9 +913,8 @@ mod tests {
assert!(loc.context_info.is_some());
}
- /// Test: set_context_info returns false for unsupported message types
#[test]
- fn test_set_context_info_unsupported() {
+ fn test_set_context_info_promotes_bare_conversation() {
let mut msg = wa::Message {
conversation: Some("Simple text".to_string()),
..Default::default()
@@ -910,7 +925,30 @@ mod tests {
..Default::default()
};
+ assert!(msg.set_context_info(context));
+ assert!(msg.conversation.is_none(), "conversation must be moved out");
+ let ext = msg
+ .extended_text_message
+ .expect("promoted to extended_text_message");
+ assert_eq!(ext.text.as_deref(), Some("Simple text"));
+ assert_eq!(
+ ext.context_info
+ .as_ref()
+ .and_then(|c| c.stanza_id.as_deref()),
+ Some("test-id")
+ );
+ }
+
+ #[test]
+ fn test_set_context_info_returns_false_on_empty_message() {
+ let mut msg = wa::Message::default();
+ let context = wa::ContextInfo {
+ stanza_id: Some("test-id".to_string()),
+ ..Default::default()
+ };
assert!(!msg.set_context_info(context));
+ assert!(msg.conversation.is_none());
+ assert!(msg.extended_text_message.is_none());
}
/// Test: build_quote_context produces correct structure.
@@ -1826,6 +1864,30 @@ mod tests {
assert!(msg.is_view_once());
}
+ #[test]
+ fn set_ephemeral_expiration_promotes_bare_conversation_to_extended_text() {
+ let mut msg = wa::Message {
+ conversation: Some("hello".to_string()),
+ ..Default::default()
+ };
+ assert!(msg.set_ephemeral_expiration(86400));
+ assert!(msg.conversation.is_none());
+ let ext = msg.extended_text_message.unwrap();
+ assert_eq!(ext.text.as_deref(), Some("hello"));
+ assert_eq!(
+ ext.context_info.as_ref().and_then(|c| c.expiration),
+ Some(86400)
+ );
+ }
+
+ #[test]
+ fn set_ephemeral_expiration_returns_false_on_empty_message() {
+ let mut msg = wa::Message::default();
+ assert!(!msg.set_ephemeral_expiration(60));
+ assert!(msg.conversation.is_none());
+ assert!(msg.extended_text_message.is_none());
+ }
+
#[test]
fn is_view_once_detects_ephemeral_device_sent_view_once() {
let msg = wa::Message {
diff --git a/wacore/src/stanza/groups.rs b/wacore/src/stanza/groups.rs
index bf582a8c9..d7ddd6b07 100644
--- a/wacore/src/stanza/groups.rs
+++ b/wacore/src/stanza/groups.rs
@@ -211,6 +211,76 @@ pub enum GroupNotificationAction {
#[wire(skip)]
raw: Node,
},
+ /// `` — Subgroup admin elevated (community parent).
+ #[wire = "linked_group_promote"]
+ LinkedGroupPromote {
+ participants: Vec,
+ },
+ /// `` — Subgroup admin demoted.
+ #[wire = "linked_group_demote"]
+ LinkedGroupDemote {
+ participants: Vec,
+ },
+
+ // -- State toggles --
+ /// `` — Group suspended by Meta moderation.
+ #[wire = "suspended"]
+ Suspended,
+ /// `` — Suspension lifted.
+ #[wire = "unsuspended"]
+ Unsuspended,
+ /// `` — Community auto-add to general chat disabled.
+ #[wire = "auto_add_disabled"]
+ AutoAddDisabled,
+ /// `` — Group routed through CAPI hosted server.
+ #[wire = "is_capi_hosted_group"]
+ IsCapiHostedGroup,
+ /// `` — Integrity check toggle.
+ #[wire = "group_safety_check"]
+ GroupSafetyCheck,
+ /// `` — Limit sharing toggle. The
+ /// optional `trigger` attribute (range 0..20 per
+ /// `WASmaxInGroupsGroupInfoMixin`) identifies the source of the change.
+ #[wire = "limit_sharing_enabled"]
+ LimitSharingEnabled { trigger: Option },
+ /// `` — Admins may receive report alerts.
+ #[wire = "allow_admin_reports"]
+ AllowAdminReports,
+ /// `` — Admin reports disabled.
+ #[wire = "not_allow_admin_reports"]
+ NotAllowAdminReports,
+ /// `` — Generic reports notification.
+ #[wire = "reports"]
+ Reports,
+ /// `` — Community permits members to
+ /// create subgroups without admin approval.
+ #[wire = "allow_non_admin_sub_group_creation"]
+ AllowNonAdminSubGroupCreation,
+ /// `` — Community restricts
+ /// subgroup creation to admins.
+ #[wire = "not_allow_non_admin_sub_group_creation"]
+ NotAllowNonAdminSubGroupCreation,
+
+ // -- Subgroup suggestions (community) --
+ /// `` — Suggested subgroup added.
+ #[wire = "created_sub_group_suggestion"]
+ CreatedSubGroupSuggestion {
+ #[wire(skip)]
+ raw: Node,
+ },
+ /// `` — Subgroup suggestion revoked.
+ #[wire = "revoked_sub_group_suggestions"]
+ RevokedSubGroupSuggestions {
+ #[wire(skip)]
+ raw: Node,
+ },
+
+ /// `` — Participant changed their phone number; new
+ /// participant data is in the `` child.
+ #[wire = "change_number"]
+ ChangeNumber {
+ participants: Vec,
+ },
// -- Catch-all --
/// Unknown child tag — preserved for forward compatibility. The `tag`
@@ -220,10 +290,9 @@ pub enum GroupNotificationAction {
}
impl GroupNotification {
- /// Parse from a `NodeRef`.
- ///
- /// Most fields are parsed zero-copy. Only `Create`/`Link`/`Unlink` actions
- /// call `.to_owned()` on their specific child node (structurally required to store `raw: Node`).
+ /// Parse from a `NodeRef`. Most fields are zero-copy; `Create`, `Link`,
+ /// `Unlink`, `CreatedSubGroupSuggestion` and `RevokedSubGroupSuggestions`
+ /// call `.to_owned()` to store their child as `raw: Node`.
pub fn try_from_node_ref(node: &NodeRef<'_>) -> Option {
let mut attrs = node.attrs();
let group_jid = attrs.optional_jid("from")?;
@@ -258,7 +327,9 @@ impl GroupNotification {
/// function. If the `#[wire = "..."]` attribute on a variant changes, both
/// the serializer and this dispatcher track it automatically.
///
-/// Only `Create`/`Link`/`Unlink` call `.to_owned()` because those variants store `raw: Node`.
+/// `Create`, `Link`, `Unlink`, `CreatedSubGroupSuggestion` and
+/// `RevokedSubGroupSuggestions` call `.to_owned()` because those variants
+/// store `raw: Node`.
fn parse_action(node: &NodeRef<'_>) -> Option {
use GroupNotificationActionTag as T;
use wacore_binary::NodeContentRef;
@@ -334,8 +405,15 @@ fn parse_action(node: &NodeRef<'_>) -> Option {
// below produce `Ephemeral { expiration: 0, trigger: None }`, matching
// WA Web's collapse of the two wire tags into one action.
T::Ephemeral => GroupNotificationAction::Ephemeral {
- expiration: node.attrs().optional_u64("expiration").unwrap_or(0) as u32,
- trigger: node.attrs().optional_u64("trigger").map(|t| t as u32),
+ 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
@@ -386,7 +464,11 @@ fn parse_action(node: &NodeRef<'_>) -> Option {
},
T::RevokeInvite => GroupNotificationAction::RevokeInvite,
T::GrowthLocked => GroupNotificationAction::GrowthLocked {
- expiration: node.attrs().optional_u64("expiration").unwrap_or(0) as u32,
+ expiration: node
+ .attrs()
+ .optional_u64("expiration")
+ .and_then(|t| t.try_into().ok())
+ .unwrap_or(0),
lock_type: node
.attrs()
.optional_string("type")
@@ -426,6 +508,40 @@ fn parse_action(node: &NodeRef<'_>) -> Option {
.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 {
+ // try_into so >u32::MAX yields None instead of silently truncating.
+ 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 {
+ participants: parse_participants(node),
+ },
T::Unknown(_) => GroupNotificationAction::Unknown {
tag: node.tag.to_string(),
},
@@ -840,6 +956,169 @@ mod tests {
}
}
+ #[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() {
+ // trigger > u32::MAX must yield None instead of truncating.
+ 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() {
+ // expiration > u32::MAX falls back to 0; trigger > u32::MAX yields 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_carries_participants() {
+ let node = make_notification(vec![
+ NodeBuilder::new("change_number")
+ .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 { participants } => {
+ assert_eq!(participants.len(), 1);
+ }
+ other => panic!("expected ChangeNumber, got {:?}", other),
+ }
+ }
+
#[test]
fn test_parse_unknown_tag() {
let node = make_notification(vec![NodeBuilder::new("some_future_feature").build()]);
@@ -939,12 +1218,39 @@ mod tests {
GroupNotificationAction::Unlink {
unlink_type: "x".into(),
unlink_reason: None,
- raw: dummy_node,
+ 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 {
+ participants: vec![],
},
GroupNotificationAction::Unknown {
tag: "future_tag".into(),
},
];
+ let _ = dummy_node;
for action in &samples {
let value = serde_json::to_value(action).expect("serialize");