From 80761939a17371f4f59bced3249c24dba719b3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 18 Apr 2026 14:12:13 -0300 Subject: [PATCH 1/3] fix(status): drop on reactions and revokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status reactions started getting NACK'd with 479 SmaxInvalid after PR #568 actually put stanzas on the wire (previously they aborted client-side). Root cause: `send_status_message` was attaching `` to every non-revoke send, including reactions. That meta describes the POSTER's privacy on their own status. WA Web attaches it only inside `WAWebEncryptAndSendStatusMsg`, which runs for status posts. Reactions route through `WAWebSendReactionMsgAction` β†’ `WAWebSendAddonMsgChatAction` β†’ `sendAddonRecord` and never visit the status-post path, so the meta is absent. Baileys doesn't emit `status_setting` at all. Only whatsapp-rust was gratuitously attaching it to reactions, which the server rejects. Extracted `wacore::send::status_carries_privacy_meta(&Message)` as a pure helper (true only for actual posts β€” not reactions, not revokes) and drove `send_status_message` off it. Six unit tests pin the classification: text post, image post, reaction, enc-reaction, revoke, non-revoke protocol message. Fixes reactions to LID-only contacts not appearing on the poster's side and the resulting silent failure in consumer bots. --- src/send.rs | 15 ++++---- wacore/src/send.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/send.rs b/src/send.rs index 7d9439972..07b8fde3a 100644 --- a/src/send.rs +++ b/src/send.rs @@ -399,19 +399,18 @@ impl Client { .await }; - // WhatsApp Web includes on non-revoke status messages. - // Revoke messages omit this node. - let is_revoke = message.protocol_message.as_ref().is_some_and(|pm| { - pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32) - }); - let extra_stanza_nodes = if is_revoke { - vec![] - } else { + // `` describes the POSTER's privacy on their own + // status. Reactions go through WA Web's addon path and never visit + // `WAWebEncryptAndSendStatusMsg`; attaching the meta on a reaction + // gets the stanza NACK'd with 479 (SmaxInvalid). Revokes also skip it. + let extra_stanza_nodes = if wacore::send::status_carries_privacy_meta(&message) { vec![ NodeBuilder::new("meta") .attr("status_setting", options.privacy.as_str()) .build(), ] + } else { + vec![] }; let prepared = match wacore::send::prepare_group_stanza( diff --git a/wacore/src/send.rs b/wacore/src/send.rs index ca81c4f80..a10db3c14 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -1424,6 +1424,19 @@ pub fn ensure_status_participants( stanza } +/// True when a `status@broadcast` message should carry the +/// `` child. Only applies to actual status posts: +/// reactions (handled server-side as addons) and revokes must omit it, per +/// `WAWebEncryptAndSendStatusMsg` vs `WAWebSendReactionMsgAction`. +pub fn status_carries_privacy_meta(message: &wa::Message) -> bool { + let is_revoke = message + .protocol_message + .as_ref() + .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32)); + let is_reaction = message.reaction_message.is_some() || message.enc_reaction_message.is_some(); + !is_revoke && !is_reaction +} + /// Dedup a pre-resolved status recipient list by user, then anchor the sender's /// own LID. Errors when no recipient was resolvable (matches WA Web's /// `WAWebLidMigrationUtils.toUserLid` + `compactMap` dropping unresolvable @@ -1565,6 +1578,81 @@ mod tests { } } + mod status_carries_privacy_meta { + use super::*; + + #[test] + fn true_for_text_post() { + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("hi".into()), + ..Default::default() + })), + ..Default::default() + }; + assert!(status_carries_privacy_meta(&msg)); + } + + #[test] + fn true_for_image_post() { + let msg = wa::Message { + image_message: Some(Box::new(wa::message::ImageMessage::default())), + ..Default::default() + }; + assert!(status_carries_privacy_meta(&msg)); + } + + #[test] + fn false_for_reaction() { + let msg = wa::Message { + reaction_message: Some(wa::message::ReactionMessage { + text: Some("πŸ’š".into()), + ..Default::default() + }), + ..Default::default() + }; + assert!( + !status_carries_privacy_meta(&msg), + "reactions must omit (479 SmaxInvalid otherwise)" + ); + } + + #[test] + fn false_for_enc_reaction() { + let msg = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage::default()), + ..Default::default() + }; + assert!(!status_carries_privacy_meta(&msg)); + } + + #[test] + fn false_for_revoke() { + let msg = wa::Message { + protocol_message: Some(Box::new(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + ..Default::default() + })), + ..Default::default() + }; + assert!(!status_carries_privacy_meta(&msg)); + } + + #[test] + fn true_for_non_revoke_protocol_message() { + // Other ProtocolMessage types (e.g., EphemeralSettings) aren't + // reactions and aren't revokes β€” treat as posts for now. + let msg = wa::Message { + protocol_message: Some(Box::new(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::EphemeralSetting as i32), + ..Default::default() + })), + ..Default::default() + }; + assert!(status_carries_privacy_meta(&msg)); + } + } + #[test] fn build_member_label_message_sets_fields() { let msg = build_member_label_message("VIP".to_string(), 1_766_847_151); From b780dbdcf79730405e483b405c5ec9c2f88a653a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 18 Apr 2026 14:58:26 -0300 Subject: [PATCH 2/3] review: unwrap message wrappers in status_carries_privacy_meta Match the existing classifier pattern in this file (see stanza_type_from_message): call unwrap_message() before inspecting protocol_message / reaction_message / enc_reaction_message, so a reaction or revoke wrapped inside ephemeral_message, device_sent_message, or any view-once wrapper is detected and the privacy meta is still omitted. Without this, a wrapped reaction would slip past and re-trigger the 479 SmaxInvalid NACK the PR is meant to fix. Two regression tests added: reaction inside ephemeral_message and revoke inside device_sent_message. --- wacore/src/send.rs | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/wacore/src/send.rs b/wacore/src/send.rs index a10db3c14..02d697a9a 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -1428,12 +1428,17 @@ pub fn ensure_status_participants( /// `` child. Only applies to actual status posts: /// reactions (handled server-side as addons) and revokes must omit it, per /// `WAWebEncryptAndSendStatusMsg` vs `WAWebSendReactionMsgAction`. +/// +/// Descends `ephemeral_message` / `device_sent_message` / view-once wrappers +/// before classifying (same as `stanza_type_from_message`), so a reaction +/// nested inside a wrapper cannot slip past and re-trigger 479. pub fn status_carries_privacy_meta(message: &wa::Message) -> bool { - let is_revoke = message + let msg = unwrap_message(message); + let is_revoke = msg .protocol_message .as_ref() .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32)); - let is_reaction = message.reaction_message.is_some() || message.enc_reaction_message.is_some(); + let is_reaction = msg.reaction_message.is_some() || msg.enc_reaction_message.is_some(); !is_revoke && !is_reaction } @@ -1651,6 +1656,41 @@ mod tests { }; assert!(status_carries_privacy_meta(&msg)); } + + #[test] + fn false_for_reaction_inside_ephemeral_wrapper() { + let inner = wa::Message { + reaction_message: Some(wa::message::ReactionMessage::default()), + ..Default::default() + }; + let msg = wa::Message { + ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { + message: Some(Box::new(inner)), + })), + ..Default::default() + }; + assert!(!status_carries_privacy_meta(&msg)); + } + + #[test] + fn false_for_revoke_inside_device_sent_wrapper() { + let inner = wa::Message { + protocol_message: Some(Box::new(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + ..Default::default() + })), + ..Default::default() + }; + let msg = wa::Message { + device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + destination_jid: Some(String::new()), + message: Some(Box::new(inner)), + ..Default::default() + })), + ..Default::default() + }; + assert!(!status_carries_privacy_meta(&msg)); + } } #[test] From 6122751bc6c8e274f55dbd44c5cbc5f20dd81751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 18 Apr 2026 15:05:25 -0300 Subject: [PATCH 3/3] perf: trim two wasted Jid clones on the status path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assemble_status_participants: check own_lid.user before allocating a non-ad copy, so the final to_non_ad() only runs when the push is actually going to happen. Previously the copy was built up-front and discarded whenever own was already in the resolved list. - resolve_skdm_targets: inspect server/user on the borrowed &Jid before deciding whether to swap LIDβ†’PN, instead of allocating `base = jid.to_non_ad()` and then throwing it away on the PN branch. Saves one Jid (user CompactString + fields) per participant that has a phone mapping β€” typically own + any resolved-from-PN recipient. Pure refactors. All 29 workspace test suites remain green. --- src/send.rs | 7 +++---- wacore/src/send.rs | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/send.rs b/src/send.rs index 07b8fde3a..50b9dab26 100644 --- a/src/send.rs +++ b/src/send.rs @@ -563,14 +563,13 @@ impl Client { .participants .iter() .map(|jid| { - let base = jid.to_non_ad(); if is_lid_mode - && base.is_lid() - && let Some(pn) = group_info.phone_jid_for_lid_user(&base.user) + && jid.is_lid() + && let Some(pn) = group_info.phone_jid_for_lid_user(&jid.user) { return pn.to_non_ad(); } - base + jid.to_non_ad() }) .collect(); diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 02d697a9a..9750c0861 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -1465,9 +1465,8 @@ where if out.is_empty() { anyhow::bail!("No valid status recipients after LID resolution"); } - let own_base = own_lid.to_non_ad(); - if !out.iter().any(|r| r.user == own_base.user) { - out.push(own_base); + if !out.iter().any(|r| r.user == own_lid.user) { + out.push(own_lid.to_non_ad()); } Ok(out) }