From ff679ccc0fbad011ff2dfb2c7728e41575d1c66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 18 Mar 2026 15:21:36 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20newsletter=20(channel)=20Phase=201?= =?UTF-8?q?=20=E2=80=94=20core=20types=20and=20read-only=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add newsletter feature module with read-only Mex GraphQL operations: - list_subscribed() — list all subscribed newsletters - get_metadata(&Jid) — fetch newsletter metadata by JID - get_metadata_by_invite(&str) — fetch metadata by invite code New types: NewsletterMetadata, NewsletterVerification, NewsletterState, NewsletterRole, exposed via client.newsletter() feature handle. Also adds: - Jid::newsletter() factory method for zero-alloc JID construction - Mex document ID constants for all newsletter GraphQL operations - E2E test stubs for list_subscribed and get_metadata --- src/features/mod.rs | 5 + src/features/newsletter.rs | 233 ++++++++++++++++++++++++++++++++++ src/lib.rs | 7 +- tests/e2e/tests/newsletter.rs | 64 ++++++++++ wacore/binary/src/jid.rs | 9 ++ wacore/src/iq/mod.rs | 1 + wacore/src/iq/newsletter.rs | 20 +++ 7 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 src/features/newsletter.rs create mode 100644 tests/e2e/tests/newsletter.rs create mode 100644 wacore/src/iq/newsletter.rs diff --git a/src/features/mod.rs b/src/features/mod.rs index be686ce2e..f4272e003 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -4,6 +4,7 @@ mod chatstate; mod contacts; mod groups; mod mex; +mod newsletter; mod presence; mod profile; pub(crate) mod status; @@ -25,6 +26,10 @@ pub use groups::{ pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; +pub use newsletter::{ + Newsletter, NewsletterMetadata, NewsletterRole, NewsletterState, NewsletterVerification, +}; + pub use presence::{Presence, PresenceError, PresenceStatus}; pub use profile::{Profile, SetProfilePictureResponse}; diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs new file mode 100644 index 000000000..398ac6026 --- /dev/null +++ b/src/features/newsletter.rs @@ -0,0 +1,233 @@ +//! Newsletter (Channel) feature. +//! +//! Provides methods for listing, fetching, and managing newsletter channels +//! via the MEX (GraphQL) protocol layer. + +use crate::client::Client; +use crate::features::mex::{MexError, MexRequest}; +use serde_json::json; +use wacore_binary::jid::Jid; + +// Types + +/// Newsletter verification status. +#[derive(Debug, Clone)] +pub enum NewsletterVerification { + Verified, + Unverified, +} + +/// Newsletter state. +#[derive(Debug, Clone)] +pub enum NewsletterState { + Active, + Suspended, + Geosuspended, +} + +/// The viewer's role in a newsletter. +#[derive(Debug, Clone)] +pub enum NewsletterRole { + Owner, + Admin, + Subscriber, + Guest, +} + +/// Metadata for a newsletter (channel). +#[derive(Debug, Clone)] +pub struct NewsletterMetadata { + pub jid: Jid, + pub name: String, + pub description: Option, + pub subscriber_count: u64, + pub verification: NewsletterVerification, + pub state: NewsletterState, + pub picture_url: Option, + pub preview_url: Option, + pub invite_code: Option, + pub role: Option, + pub creation_time: Option, +} + +/// Feature handle for newsletter (channel) operations. +pub struct Newsletter<'a> { + client: &'a Client, +} + +impl<'a> Newsletter<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// List all newsletters the user is subscribed to. + pub async fn list_subscribed(&self) -> Result, MexError> { + let response = self + .client + .mex() + .query(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::LIST_SUBSCRIBED, + variables: json!({}), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletters = data["xwa2_newsletter_subscribed"] + .as_array() + .ok_or_else(|| { + MexError::PayloadParsing("missing xwa2_newsletter_subscribed array".into()) + })?; + + newsletters.iter().map(parse_newsletter_metadata).collect() + } + + /// Fetch metadata for a newsletter by its JID. + pub async fn get_metadata(&self, jid: &Jid) -> Result { + let response = self + .client + .mex() + .query(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::FETCH_METADATA, + variables: json!({ + "input": { + "key": jid.to_string(), + "type": "JID", + "view_role": "GUEST" + }, + "fetch_viewer_metadata": true, + "fetch_full_image": true, + "fetch_creation_time": true + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletter = &data["xwa2_newsletter"]; + if newsletter.is_null() { + return Err(MexError::PayloadParsing(format!( + "newsletter not found: {}", + jid + ))); + } + parse_newsletter_metadata(newsletter) + } + + /// Fetch metadata for a newsletter by its invite code. + pub async fn get_metadata_by_invite( + &self, + invite_code: &str, + ) -> Result { + let response = self + .client + .mex() + .query(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::FETCH_METADATA, + variables: json!({ + "input": { + "key": invite_code, + "type": "INVITE", + "view_role": "GUEST" + }, + "fetch_viewer_metadata": true, + "fetch_full_image": true, + "fetch_creation_time": true + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletter = &data["xwa2_newsletter"]; + if newsletter.is_null() { + return Err(MexError::PayloadParsing(format!( + "newsletter not found for invite: {}", + invite_code + ))); + } + parse_newsletter_metadata(newsletter) + } +} + +impl Client { + /// Access newsletter (channel) operations. + #[inline] + pub fn newsletter(&self) -> Newsletter<'_> { + Newsletter::new(self) + } +} + +// JSON parsing helper + +fn parse_newsletter_metadata(value: &serde_json::Value) -> Result { + let jid_str = value["id"] + .as_str() + .ok_or_else(|| MexError::PayloadParsing("missing newsletter id".into()))?; + let jid: Jid = jid_str + .parse() + .map_err(|e| MexError::PayloadParsing(format!("invalid newsletter JID: {e}")))?; + + let thread = &value["thread_metadata"]; + + let name = thread["name"]["text"].as_str().unwrap_or("").to_string(); + let description = thread["description"]["text"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let subscriber_count = thread["subscribers_count"] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let verification = match thread["verification"].as_str() { + Some("VERIFIED") => NewsletterVerification::Verified, + _ => NewsletterVerification::Unverified, + }; + + let state = match value["state"]["type"].as_str() { + Some("suspended") => NewsletterState::Suspended, + Some("geosuspended") => NewsletterState::Geosuspended, + _ => NewsletterState::Active, + }; + + let picture_url = thread["picture"]["direct_path"] + .as_str() + .map(|s| s.to_string()); + let preview_url = thread["preview"]["direct_path"] + .as_str() + .map(|s| s.to_string()); + let invite_code = thread["invite"].as_str().map(|s| s.to_string()); + + let creation_time = thread["creation_time"] + .as_str() + .and_then(|s| s.parse::().ok()); + + let role = value["viewer_metadata"]["role"] + .as_str() + .and_then(|r| match r { + "owner" => Some(NewsletterRole::Owner), + "admin" => Some(NewsletterRole::Admin), + "subscriber" => Some(NewsletterRole::Subscriber), + "guest" => Some(NewsletterRole::Guest), + _ => None, + }); + + Ok(NewsletterMetadata { + jid, + name, + description, + subscriber_count, + verification, + state, + picture_url, + preview_url, + invite_code, + role, + creation_time, + }) +} diff --git a/src/lib.rs b/src/lib.rs index 75ffff496..2f3b8355c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,9 +50,10 @@ pub use features::{ CreateGroupResult, GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupSubject, Groups, IsOnWhatsAppResult, MemberAddMode, MemberLinkMode, MembershipApprovalMode, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, Profile, - ProfilePicture, SetProfilePictureResponse, Status, StatusPrivacySetting, StatusSendOptions, - TcToken, UserInfo, + MexResponse, Newsletter, NewsletterMetadata, NewsletterRole, NewsletterState, + NewsletterVerification, ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, + Profile, ProfilePicture, SetProfilePictureResponse, Status, StatusPrivacySetting, + StatusSendOptions, TcToken, UserInfo, }; pub mod bot; diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs new file mode 100644 index 000000000..c9d45a225 --- /dev/null +++ b/tests/e2e/tests/newsletter.rs @@ -0,0 +1,64 @@ +use e2e_tests::TestClient; +use log::info; + +#[tokio::test] +async fn test_list_subscribed_newsletters() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_list").await?; + + let newsletters = client.client.newsletter().list_subscribed().await?; + // Mock server should have at least one newsletter + assert!( + !newsletters.is_empty(), + "should have subscribed newsletters" + ); + + let first = &newsletters[0]; + assert!(!first.name.is_empty(), "newsletter should have a name"); + assert!( + !first.jid.user.is_empty(), + "newsletter should have a JID user" + ); + + info!( + "Listed {} subscribed newsletters, first: {} ({})", + newsletters.len(), + first.name, + first.jid + ); + + client.disconnect().await; + + Ok(()) +} + +#[tokio::test] +async fn test_get_newsletter_metadata() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_meta").await?; + + // First list to get a known newsletter JID + let newsletters = client.client.newsletter().list_subscribed().await?; + assert!(!newsletters.is_empty(), "need at least one newsletter"); + + let jid = &newsletters[0].jid; + let metadata = client.client.newsletter().get_metadata(jid).await?; + + assert_eq!(metadata.jid, *jid); + assert!(!metadata.name.is_empty(), "newsletter should have a name"); + assert!( + metadata.subscriber_count > 0, + "newsletter should have subscribers" + ); + + info!( + "Fetched metadata for {}: name={}, subscribers={}", + metadata.jid, metadata.name, metadata.subscriber_count + ); + + client.disconnect().await; + + Ok(()) +} diff --git a/wacore/binary/src/jid.rs b/wacore/binary/src/jid.rs index fa94f16db..5811321e8 100644 --- a/wacore/binary/src/jid.rs +++ b/wacore/binary/src/jid.rs @@ -359,6 +359,15 @@ impl Jid { } } + /// Create a newsletter (channel) JID (newsletter server). + pub fn newsletter(id: impl Into) -> Self { + Self { + user: id.into(), + server: Cow::Borrowed(NEWSLETTER_SERVER), + ..Default::default() + } + } + /// Create a phone number JID with device ID pub fn pn_device(user: impl Into, device: u16) -> Self { Self { diff --git a/wacore/src/iq/mod.rs b/wacore/src/iq/mod.rs index 10663ee75..9e982f25a 100644 --- a/wacore/src/iq/mod.rs +++ b/wacore/src/iq/mod.rs @@ -6,6 +6,7 @@ pub mod groups; pub mod keepalive; pub mod mediaconn; pub mod mex; +pub mod newsletter; pub mod node; pub mod passive; pub mod prekeys; diff --git a/wacore/src/iq/newsletter.rs b/wacore/src/iq/newsletter.rs new file mode 100644 index 000000000..21855b775 --- /dev/null +++ b/wacore/src/iq/newsletter.rs @@ -0,0 +1,20 @@ +//! Newsletter (Channel) IQ specifications. +//! +//! Newsletters use two protocol layers: +//! - Mex (GraphQL) for metadata/management operations +//! - Standard IQ (xmlns="newsletter") for message operations + +/// Mex document IDs for newsletter GraphQL operations. +pub mod mex_docs { + pub const LIST_SUBSCRIBED: &str = "33101596156151910"; + pub const FETCH_METADATA: &str = "25383075034668475"; + pub const FETCH_DEHYDRATED: &str = "30328461880085868"; + pub const CREATE: &str = "25149874324715067"; + pub const UPDATE: &str = "24250201037901610"; + pub const JOIN: &str = "24404358912487870"; + pub const FETCH_ADMIN_COUNT: &str = "29186079397702825"; + pub const FETCH_ADMIN_CAPABILITIES: &str = "9801384413216421"; + pub const FETCH_PENDING_INVITES: &str = "9783111038412085"; + pub const FETCH_SUBSCRIBERS: &str = "9537574256318798"; + pub const FETCH_REACTION_SENDERS: &str = "29575462448733991"; +} From a838dc8873bf42794227c3200b7c9e93283e63b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 18 Mar 2026 15:27:39 -0300 Subject: [PATCH 2/8] feat: add newsletter create() and join(), working e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add create(name, description) via Mex mutation (doc 25149874324715067) - Add join(jid) via Mex mutation (doc 24404358912487870) - Rewrite e2e tests to: create → list → get_metadata → get_by_invite → join - All 3 e2e tests pass against mock server --- src/features/newsletter.rs | 62 +++++++++++++++++++ tests/e2e/tests/newsletter.rs | 113 ++++++++++++++++++++++++---------- 2 files changed, 144 insertions(+), 31 deletions(-) diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 398ac6026..2f699ebef 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -116,6 +116,68 @@ impl<'a> Newsletter<'a> { parse_newsletter_metadata(newsletter) } + /// Create a new newsletter. + /// + /// Returns the metadata of the newly created newsletter. + pub async fn create( + &self, + name: &str, + description: Option<&str>, + ) -> Result { + let mut input = json!({ "name": name }); + if let Some(desc) = description { + input["description"] = json!(desc); + } + + let response = self + .client + .mex() + .mutate(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::CREATE, + variables: json!({ "input": input }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletter = &data["xwa2_newsletter_create"]; + if newsletter.is_null() { + return Err(MexError::PayloadParsing( + "newsletter creation failed".into(), + )); + } + parse_newsletter_metadata(newsletter) + } + + /// Join (subscribe to) a newsletter. + /// + /// Returns the newsletter metadata with the viewer's role set to `Subscriber`. + pub async fn join(&self, jid: &Jid) -> Result { + let response = self + .client + .mex() + .mutate(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::JOIN, + variables: json!({ + "newsletter_id": jid.to_string() + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletter = &data["xwa2_newsletter_join_v2"]; + if newsletter.is_null() { + return Err(MexError::PayloadParsing(format!( + "failed to join newsletter: {}", + jid + ))); + } + parse_newsletter_metadata(newsletter) + } + /// Fetch metadata for a newsletter by its invite code. pub async fn get_metadata_by_invite( &self, diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index c9d45a225..404775ed2 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -2,63 +2,114 @@ use e2e_tests::TestClient; use log::info; #[tokio::test] -async fn test_list_subscribed_newsletters() -> anyhow::Result<()> { +async fn test_newsletter_create_and_list() -> anyhow::Result<()> { let _ = env_logger::builder().is_test(true).try_init(); - let client = TestClient::connect("e2e_newsletter_list").await?; + let client = TestClient::connect("e2e_newsletter_create").await?; + + // Create a newsletter + let created = client + .client + .newsletter() + .create("Test Channel", Some("A test newsletter")) + .await?; - let newsletters = client.client.newsletter().list_subscribed().await?; - // Mock server should have at least one newsletter assert!( - !newsletters.is_empty(), - "should have subscribed newsletters" + !created.name.is_empty(), + "created newsletter should have a name" ); - - let first = &newsletters[0]; - assert!(!first.name.is_empty(), "newsletter should have a name"); + assert_eq!(created.name, "Test Channel"); assert!( - !first.jid.user.is_empty(), - "newsletter should have a JID user" + created.jid.server == "newsletter", + "JID should be newsletter: {}", + created.jid ); - info!( - "Listed {} subscribed newsletters, first: {} ({})", - newsletters.len(), - first.name, - first.jid + info!("Created newsletter: {} ({})", created.name, created.jid); + + // List subscribed — should include the one we just created + let newsletters = client.client.newsletter().list_subscribed().await?; + assert!( + newsletters.iter().any(|n| n.jid == created.jid), + "created newsletter should appear in subscribed list" ); - client.disconnect().await; + info!("list_subscribed returned {} newsletters", newsletters.len()); + client.disconnect().await; Ok(()) } #[tokio::test] -async fn test_get_newsletter_metadata() -> anyhow::Result<()> { +async fn test_newsletter_get_metadata() -> anyhow::Result<()> { let _ = env_logger::builder().is_test(true).try_init(); let client = TestClient::connect("e2e_newsletter_meta").await?; - // First list to get a known newsletter JID - let newsletters = client.client.newsletter().list_subscribed().await?; - assert!(!newsletters.is_empty(), "need at least one newsletter"); + // Create a newsletter first + let created = client + .client + .newsletter() + .create("Metadata Test", None) + .await?; - let jid = &newsletters[0].jid; - let metadata = client.client.newsletter().get_metadata(jid).await?; + // Fetch metadata by JID + let metadata = client + .client + .newsletter() + .get_metadata(&created.jid) + .await?; - assert_eq!(metadata.jid, *jid); - assert!(!metadata.name.is_empty(), "newsletter should have a name"); - assert!( - metadata.subscriber_count > 0, - "newsletter should have subscribers" - ); + assert_eq!(metadata.jid, created.jid); + assert_eq!(metadata.name, "Metadata Test"); info!( - "Fetched metadata for {}: name={}, subscribers={}", - metadata.jid, metadata.name, metadata.subscriber_count + "Fetched metadata: name='{}', subscribers={}, invite={:?}", + metadata.name, metadata.subscriber_count, metadata.invite_code ); + // Fetch by invite code if available + if let Some(invite) = &metadata.invite_code { + let by_invite = client + .client + .newsletter() + .get_metadata_by_invite(invite) + .await?; + assert_eq!(by_invite.jid, created.jid); + info!("Fetched by invite code '{}': OK", invite); + } + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_newsletter_join() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + // Client A creates a newsletter + let client_a = TestClient::connect("e2e_newsletter_join_a").await?; + let created = client_a + .client + .newsletter() + .create("Join Test Channel", None) + .await?; + + info!("Client A created newsletter: {}", created.jid); + + // Client B joins the newsletter + let client_b = TestClient::connect("e2e_newsletter_join_b").await?; + let joined = client_b.client.newsletter().join(&created.jid).await?; + + assert_eq!(joined.jid, created.jid); + assert_eq!(joined.name, "Join Test Channel"); + + info!( + "Client B joined newsletter '{}' — role: {:?}", + joined.name, joined.role + ); + client_a.disconnect().await; + client_b.disconnect().await; Ok(()) } From 03a4aabd0caba2c22fd5d53ab30aa506dc0ce106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 18 Mar 2026 15:41:23 -0300 Subject: [PATCH 3/8] feat: add newsletter leave, update, and e2e tests - Add leave(jid) via Mex mutation (doc 9767147403369991) - Add update(jid, name, description) via Mex mutation (doc 24250201037901610) - Add LEAVE doc ID constant - E2E tests: 5/5 passing (create, list, metadata, join, leave, update) --- src/features/newsletter.rs | 65 +++++++++++++++++++++++++++++++++ tests/e2e/tests/newsletter.rs | 69 +++++++++++++++++++++++++++++++++++ wacore/src/iq/newsletter.rs | 4 ++ 3 files changed, 138 insertions(+) diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 2f699ebef..8f2a633a1 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -178,6 +178,71 @@ impl<'a> Newsletter<'a> { parse_newsletter_metadata(newsletter) } + /// Leave (unsubscribe from) a newsletter. + pub async fn leave(&self, jid: &Jid) -> Result<(), MexError> { + let response = self + .client + .mex() + .mutate(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::LEAVE, + variables: json!({ + "newsletter_id": jid.to_string() + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + if data["xwa2_newsletter_leave_v2"].is_null() { + return Err(MexError::PayloadParsing(format!( + "failed to leave newsletter: {}", + jid + ))); + } + Ok(()) + } + + /// Update a newsletter's name and/or description. + pub async fn update( + &self, + jid: &Jid, + name: Option<&str>, + description: Option<&str>, + ) -> Result { + let mut updates = json!({}); + if let Some(name) = name { + updates["name"] = json!(name); + } + if let Some(desc) = description { + updates["description"] = json!(desc); + } + + let response = self + .client + .mex() + .mutate(MexRequest { + doc_id: wacore::iq::newsletter::mex_docs::UPDATE, + variables: json!({ + "newsletter_id": jid.to_string(), + "updates": updates + }), + }) + .await?; + + let data = response + .data + .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + let newsletter = &data["xwa2_newsletter_update"]; + if newsletter.is_null() { + return Err(MexError::PayloadParsing(format!( + "failed to update newsletter: {}", + jid + ))); + } + parse_newsletter_metadata(newsletter) + } + /// Fetch metadata for a newsletter by its invite code. pub async fn get_metadata_by_invite( &self, diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 404775ed2..553333c1f 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -113,3 +113,72 @@ async fn test_newsletter_join() -> anyhow::Result<()> { client_b.disconnect().await; Ok(()) } + +#[tokio::test] +async fn test_newsletter_leave() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_leave").await?; + + // Create and join a newsletter + let created = client + .client + .newsletter() + .create("Leave Test Channel", None) + .await?; + + info!("Created newsletter: {}", created.jid); + + // Leave it + client.client.newsletter().leave(&created.jid).await?; + info!("Left newsletter: {}", created.jid); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_newsletter_update() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_update").await?; + + // Create a newsletter + let created = client + .client + .newsletter() + .create("Original Name", Some("Original description")) + .await?; + + info!("Created newsletter: {} ({})", created.name, created.jid); + + // Update name and description + let updated = client + .client + .newsletter() + .update( + &created.jid, + Some("Updated Name"), + Some("Updated description"), + ) + .await?; + + assert_eq!(updated.jid, created.jid); + assert_eq!(updated.name, "Updated Name"); + + info!( + "Updated newsletter: name='{}', desc={:?}", + updated.name, updated.description + ); + + // Verify via metadata fetch + let metadata = client + .client + .newsletter() + .get_metadata(&created.jid) + .await?; + assert_eq!(metadata.name, "Updated Name"); + + client.disconnect().await; + Ok(()) +} diff --git a/wacore/src/iq/newsletter.rs b/wacore/src/iq/newsletter.rs index 21855b775..978c001e3 100644 --- a/wacore/src/iq/newsletter.rs +++ b/wacore/src/iq/newsletter.rs @@ -17,4 +17,8 @@ pub mod mex_docs { pub const FETCH_PENDING_INVITES: &str = "9783111038412085"; pub const FETCH_SUBSCRIBERS: &str = "9537574256318798"; pub const FETCH_REACTION_SENDERS: &str = "29575462448733991"; + pub const LEAVE: &str = "9767147403369991"; } + +/// IQ namespace for newsletter operations (message history, reactions, live updates). +pub const NEWSLETTER_XMLNS: &str = "newsletter"; From a7cd94ff4bcc665c086341a8d53571ed84495a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 18 Mar 2026 15:49:25 -0300 Subject: [PATCH 4/8] feat: add newsletter send_message, get_messages with pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - send_message(jid, message) — plaintext stanza (no Signal encryption) - get_messages(jid, count, before) — IQ xmlns="newsletter" with server_id pagination cursor - NewsletterMessage type with decoded protobuf + reaction counts - Leave mutation doc ID (9767147403369991, from WA Web JS) - E2E tests: 7/7 passing (send, history, pagination, all CRUD ops) --- src/features/mod.rs | 3 +- src/features/newsletter.rs | 180 +++++++++++++++++++++++++++++++++- tests/e2e/tests/newsletter.rs | 121 +++++++++++++++++++++++ 3 files changed, 301 insertions(+), 3 deletions(-) diff --git a/src/features/mod.rs b/src/features/mod.rs index f4272e003..a4f3409bc 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -27,7 +27,8 @@ pub use groups::{ pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; pub use newsletter::{ - Newsletter, NewsletterMetadata, NewsletterRole, NewsletterState, NewsletterVerification, + Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, + NewsletterState, NewsletterVerification, }; pub use presence::{Presence, PresenceError, PresenceStatus}; diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 8f2a633a1..f0f45408f 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -1,12 +1,19 @@ //! Newsletter (Channel) feature. //! -//! Provides methods for listing, fetching, and managing newsletter channels -//! via the MEX (GraphQL) protocol layer. +//! Provides methods for listing, fetching, and managing newsletter channels. +//! Uses MEX (GraphQL) for metadata/management and standard IQ for message operations. +//! Newsletter messages are plaintext (no Signal E2E encryption). use crate::client::Client; use crate::features::mex::{MexError, MexRequest}; +use prost::Message as ProtoMessage; use serde_json::json; +use wacore::iq::newsletter::NEWSLETTER_XMLNS; +use wacore::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::Jid; +use wacore_binary::node::{Node, NodeContent}; +use waproto::whatsapp as wa; // Types @@ -50,6 +57,30 @@ pub struct NewsletterMetadata { pub creation_time: Option, } +/// A reaction count on a newsletter message. +#[derive(Debug, Clone)] +pub struct NewsletterReactionCount { + pub code: String, + pub count: u64, +} + +/// A message from a newsletter's history. +#[derive(Debug, Clone)] +pub struct NewsletterMessage { + /// Server-assigned message ID (monotonic, used for pagination cursors). + pub server_id: u64, + /// Message timestamp (Unix seconds). + pub timestamp: u64, + /// Message type ("text", "media", etc.). + pub message_type: String, + /// Whether the viewer is the sender. + pub is_sender: bool, + /// Decoded protobuf message (from `` bytes). + pub message: Option<wa::Message>, + /// Reaction counts on this message. + pub reactions: Vec<NewsletterReactionCount>, +} + /// Feature handle for newsletter (channel) operations. pub struct Newsletter<'a> { client: &'a Client, @@ -278,6 +309,56 @@ impl<'a> Newsletter<'a> { } parse_newsletter_metadata(newsletter) } + + // ─── Message operations ──────────────────────────────────────────── + + /// Send a message to a newsletter. + /// + /// Newsletter messages are plaintext (no Signal E2E encryption). + /// Returns the message ID assigned by the client. + pub async fn send_message( + &self, + jid: &Jid, + message: &wa::Message, + ) -> Result<String, anyhow::Error> { + let request_id = self.client.generate_message_id().await; + let encoded = message.encode_to_vec(); + + let stanza = NodeBuilder::new("message") + .attr("to", jid.clone()) + .attr("type", "text") + .attr("id", &request_id) + .children([NodeBuilder::new("plaintext").bytes(encoded).build()]) + .build(); + + self.client.send_node(stanza).await?; + Ok(request_id) + } + + /// Fetch message history from a newsletter. + /// + /// Returns up to `count` messages. Use `before` with a `server_id` from a previous + /// response to paginate backwards through history. + pub async fn get_messages( + &self, + jid: &Jid, + count: u32, + before: Option<u64>, + ) -> Result<Vec<NewsletterMessage>, anyhow::Error> { + let mut messages_node = NodeBuilder::new("messages").attr("count", count.to_string()); + if let Some(before_id) = before { + messages_node = messages_node.attr("before", before_id.to_string()); + } + + let iq = InfoQuery::get( + NEWSLETTER_XMLNS, + jid.clone(), + Some(NodeContent::Nodes(vec![messages_node.build()])), + ); + + let response = self.client.send_iq(iq).await?; + parse_newsletter_messages_response(&response) + } } impl Client { @@ -358,3 +439,98 @@ fn parse_newsletter_metadata(value: &serde_json::Value) -> Result<NewsletterMeta creation_time, }) } + +// Node response parsing helpers + +/// Parse the IQ response for newsletter message history. +/// +/// Response format: +/// ```xml +/// <messages jid="NL_JID" t="TS"> +/// <message id="..." server_id="123" t="TS" type="text" [is_sender="true"]> +/// <plaintext>...</plaintext> +/// <reactions><reaction code="👍" count="3"/></reactions> +/// </message> +/// </messages> +/// ``` +fn parse_newsletter_messages_response( + response: &Node, +) -> Result<Vec<NewsletterMessage>, anyhow::Error> { + // Response is the IQ result node; find <messages> child + let messages_node = response + .get_optional_child("messages") + .ok_or_else(|| anyhow::anyhow!("missing <messages> in newsletter response"))?; + + let children = match messages_node.children() { + Some(c) => c, + None => return Ok(vec![]), + }; + + let mut result = Vec::with_capacity(children.len()); + for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") { + let server_id = msg_node + .attrs + .get("server_id") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + + let timestamp = msg_node + .attrs + .get("t") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + + let message_type = msg_node + .attrs + .get("type") + .map(|v| v.as_str().into_owned()) + .unwrap_or_default(); + + let is_sender = msg_node.attrs.get("is_sender").is_some_and(|v| v == "true"); + + // Decode <plaintext> protobuf bytes + let message = msg_node + .get_optional_child("plaintext") + .and_then(|pt| match &pt.content { + Some(NodeContent::Bytes(bytes)) => wa::Message::decode(bytes.as_slice()).ok(), + _ => None, + }); + + // Parse <reactions> counts + let mut reactions = Vec::new(); + if let Some(reactions_node) = msg_node.get_optional_child("reactions") + && let Some(reaction_children) = reactions_node.children() + { + for r in reaction_children + .iter() + .filter(|n| n.tag.as_ref() == "reaction") + { + let code = r + .attrs + .get("code") + .map(|v| v.as_str().into_owned()) + .unwrap_or_default(); + let count = r + .attrs + .get("count") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + reactions.push(NewsletterReactionCount { code, count }); + } + } + + result.push(NewsletterMessage { + server_id, + timestamp, + message_type, + is_sender, + message, + reactions, + }); + } + + Ok(result) +} diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 553333c1f..564c466dd 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -1,5 +1,6 @@ use e2e_tests::TestClient; use log::info; +use whatsapp_rust::waproto::whatsapp as wa; #[tokio::test] async fn test_newsletter_create_and_list() -> anyhow::Result<()> { @@ -182,3 +183,123 @@ async fn test_newsletter_update() -> anyhow::Result<()> { client.disconnect().await; Ok(()) } + +#[tokio::test] +async fn test_newsletter_send_and_get_messages() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_msg").await?; + + // Create a newsletter + let created = client + .client + .newsletter() + .create("Message Test Channel", None) + .await?; + + info!("Created newsletter: {}", created.jid); + + // Send a text message + let message = wa::Message { + conversation: Some("Hello from newsletter!".to_string()), + ..Default::default() + }; + let msg_id = client + .client + .newsletter() + .send_message(&created.jid, &message) + .await?; + + info!("Sent message with id: {}", msg_id); + + // Fetch message history + let messages = client + .client + .newsletter() + .get_messages(&created.jid, 50, None) + .await?; + + assert!(!messages.is_empty(), "should have at least one message"); + + let first = &messages[0]; + assert!(first.server_id > 0, "server_id should be assigned"); + assert!(first.timestamp > 0, "timestamp should be set"); + + // Verify the plaintext was decoded + if let Some(ref decoded) = first.message { + assert_eq!( + decoded.conversation.as_deref(), + Some("Hello from newsletter!") + ); + info!( + "Message decoded: server_id={}, text={:?}", + first.server_id, + decoded.conversation.as_deref() + ); + } + + info!( + "Fetched {} messages, first server_id={}", + messages.len(), + first.server_id + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_newsletter_message_pagination() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_pag").await?; + + let created = client + .client + .newsletter() + .create("Pagination Test", None) + .await?; + + // Send multiple messages + for i in 0..5 { + let msg = wa::Message { + conversation: Some(format!("Message {}", i)), + ..Default::default() + }; + client + .client + .newsletter() + .send_message(&created.jid, &msg) + .await?; + } + + // Fetch all messages + let all = client + .client + .newsletter() + .get_messages(&created.jid, 50, None) + .await?; + assert_eq!(all.len(), 5, "should have 5 messages"); + + // Paginate: get messages before the last one + let last_server_id = all.last().unwrap().server_id; + let page = client + .client + .newsletter() + .get_messages(&created.jid, 2, Some(last_server_id)) + .await?; + + assert!( + page.len() <= 4, + "page before last should have fewer messages" + ); + + info!( + "Pagination: total={}, page_before_last={}", + all.len(), + page.len() + ); + + client.disconnect().await; + Ok(()) +} From 87df1f795bcf71848af2eff18ca4340655e6b38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <jlucaso@hotmail.com> Date: Wed, 18 Mar 2026 15:54:47 -0300 Subject: [PATCH 5/8] feat: add newsletter live updates, reactions, and notification handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscribe_live_updates(jid) — IQ SET xmlns="newsletter" <live_updates/> - send_reaction(jid, server_id, emoji) — reaction message stanza - NewsletterLiveUpdate event with per-message reaction counts - Notification handler for type="newsletter" parses <live_updates> children and dispatches Event::NewsletterLiveUpdate - E2E tests: 9/9 passing (full lifecycle including reaction live updates) --- src/features/newsletter.rs | 51 +++++++++++++++++ src/handlers/notification.rs | 91 +++++++++++++++++++++++++++++ tests/e2e/tests/newsletter.rs | 105 ++++++++++++++++++++++++++++++++++ wacore/src/types/events.rs | 25 ++++++++ 4 files changed, 272 insertions(+) diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index f0f45408f..ff19cb923 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -310,6 +310,33 @@ impl<'a> Newsletter<'a> { parse_newsletter_metadata(newsletter) } + // ─── Live updates ─────────────────────────────────────────────────── + + /// Subscribe to live updates for a newsletter (reaction counts, message changes). + /// + /// The server will send `<notification type="newsletter">` stanzas with + /// `<live_updates>` children, dispatched as `Event::NewsletterLiveUpdate`. + /// Returns the subscription duration in seconds. + pub async fn subscribe_live_updates(&self, jid: &Jid) -> Result<u64, anyhow::Error> { + let iq = InfoQuery::set( + NEWSLETTER_XMLNS, + jid.clone(), + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("live_updates").build(), + ])), + ); + + let response = self.client.send_iq(iq).await?; + let duration = response + .get_optional_child("live_updates") + .and_then(|n| n.attrs.get("duration")) + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(300); + + Ok(duration) + } + // ─── Message operations ──────────────────────────────────────────── /// Send a message to a newsletter. @@ -335,6 +362,30 @@ impl<'a> Newsletter<'a> { Ok(request_id) } + /// Send a reaction to a newsletter message. + /// + /// `server_id` is the server-assigned ID of the message to react to. + /// `reaction` is the emoji code (e.g., "👍", "❤️"), or empty to remove. + pub async fn send_reaction( + &self, + jid: &Jid, + server_id: u64, + reaction: &str, + ) -> Result<(), anyhow::Error> { + let request_id = self.client.generate_message_id().await; + + let stanza = NodeBuilder::new("message") + .attr("to", jid.clone()) + .attr("type", "reaction") + .attr("id", &request_id) + .attr("server_id", server_id.to_string()) + .children([NodeBuilder::new("reaction").attr("code", reaction).build()]) + .build(); + + self.client.send_node(stanza).await?; + Ok(()) + } + /// Fetch message history from a newsletter. /// /// Returns up to `count` messages. Use `before` with a `server_id` from a previous diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index d47595fcc..2e576e1d8 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -202,6 +202,9 @@ async fn handle_notification_impl(client: &Arc<Client>, node: &Node) { // updates the contact's default ephemeral setting. handle_disappearing_mode_notification(client, node); } + "newsletter" => { + handle_newsletter_notification(client, node); + } _ => { debug!("Unhandled notification type '{notification_type}', dispatching raw event"); client @@ -1108,6 +1111,94 @@ async fn handle_group_notification(client: &Arc<Client>, node: &Node) { .dispatch(&Event::Notification(node.clone())); } +/// Handle `<notification type="newsletter">` — live updates with reaction counts. +/// +/// Format: +/// ```xml +/// <notification from="NL_JID" type="newsletter" id="..." t="..."> +/// <live_updates> +/// <messages jid="NL_JID" t="..."> +/// <message server_id="123" ...> +/// <reactions><reaction code="👍" count="3"/></reactions> +/// </message> +/// </messages> +/// </live_updates> +/// </notification> +/// ``` +fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) { + use wacore::types::events::{ + NewsletterLiveUpdate, NewsletterLiveUpdateMessage, NewsletterLiveUpdateReaction, + }; + + let newsletter_jid = node.attrs().jid("from"); + + let Some(live_updates) = node.get_optional_child("live_updates") else { + debug!( + "Newsletter notification without <live_updates>, dispatching raw: {}", + wacore::xml::DisplayableNode(node) + ); + client + .core + .event_bus + .dispatch(&Event::Notification(node.clone())); + return; + }; + + let Some(messages_node) = live_updates.get_optional_child("messages") else { + return; + }; + + let mut messages = Vec::new(); + if let Some(children) = messages_node.children() { + for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") { + let server_id = msg_node + .attrs + .get("server_id") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + + let mut reactions = Vec::new(); + if let Some(reactions_node) = msg_node.get_optional_child("reactions") + && let Some(reaction_children) = reactions_node.children() + { + for r in reaction_children + .iter() + .filter(|n| n.tag.as_ref() == "reaction") + { + let code = r + .attrs + .get("code") + .map(|v| v.as_str().into_owned()) + .unwrap_or_default(); + let count = r + .attrs + .get("count") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + reactions.push(NewsletterLiveUpdateReaction { code, count }); + } + } + + messages.push(NewsletterLiveUpdateMessage { + server_id, + reactions, + }); + } + } + + if !messages.is_empty() { + client + .core + .event_bus + .dispatch(&Event::NewsletterLiveUpdate(NewsletterLiveUpdate { + newsletter_jid, + messages, + })); + } +} + /// Handle `<notification type="disappearing_mode">` — a contact changed /// their default disappearing messages setting. /// diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 564c466dd..2fdf4982b 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -1,5 +1,6 @@ use e2e_tests::TestClient; use log::info; +use wacore::types::events::Event; use whatsapp_rust::waproto::whatsapp as wa; #[tokio::test] @@ -303,3 +304,107 @@ async fn test_newsletter_message_pagination() -> anyhow::Result<()> { client.disconnect().await; Ok(()) } + +#[tokio::test] +async fn test_newsletter_subscribe_live_updates() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + let client = TestClient::connect("e2e_newsletter_live").await?; + + let created = client + .client + .newsletter() + .create("Live Updates Test", None) + .await?; + + let duration = client + .client + .newsletter() + .subscribe_live_updates(&created.jid) + .await?; + + assert!(duration > 0, "subscription duration should be positive"); + info!( + "Subscribed to live updates for {} — duration={}s", + created.jid, duration + ); + + client.disconnect().await; + Ok(()) +} + +#[tokio::test] +async fn test_newsletter_reaction_live_update() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + + // Client A creates a newsletter and sends a message + let mut client_a = TestClient::connect("e2e_newsletter_react_a").await?; + let created = client_a + .client + .newsletter() + .create("Reaction Test", None) + .await?; + + let msg = wa::Message { + conversation: Some("React to me!".to_string()), + ..Default::default() + }; + client_a + .client + .newsletter() + .send_message(&created.jid, &msg) + .await?; + + // Get the server_id of the message we just sent + let messages = client_a + .client + .newsletter() + .get_messages(&created.jid, 1, None) + .await?; + assert!(!messages.is_empty()); + let server_id = messages[0].server_id; + info!("Sent message with server_id={}", server_id); + + // Subscribe to live updates + client_a + .client + .newsletter() + .subscribe_live_updates(&created.jid) + .await?; + + // Send a reaction (mock server echoes live_updates back to sender) + client_a + .client + .newsletter() + .send_reaction(&created.jid, server_id, "👍") + .await?; + + // Wait for the live update notification + let nl_jid = created.jid.clone(); + let event = client_a + .wait_for_event(10, move |e| { + matches!(e, Event::NewsletterLiveUpdate(update) if update.newsletter_jid == nl_jid) + }) + .await?; + + if let Event::NewsletterLiveUpdate(update) = event { + info!( + "Received live update for {} with {} message(s)", + update.newsletter_jid, + update.messages.len() + ); + assert!(!update.messages.is_empty()); + let msg_update = &update.messages[0]; + assert_eq!(msg_update.server_id, server_id); + assert!( + msg_update + .reactions + .iter() + .any(|r| r.code == "👍" && r.count > 0), + "should have thumbs up reaction" + ); + } + + client_a.disconnect().await; + Ok(()) +} diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 91af4828a..b783f8d04 100644 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -386,6 +386,31 @@ pub enum Event { /// A contact changed their default disappearing messages setting. DisappearingModeChanged(DisappearingModeChanged), + + /// Newsletter live update (reaction counts changed, message updates, etc.). + NewsletterLiveUpdate(NewsletterLiveUpdate), +} + +/// A newsletter live update notification, typically containing updated +/// reaction counts for one or more messages. +#[derive(Debug, Clone, Serialize)] +pub struct NewsletterLiveUpdate { + pub newsletter_jid: Jid, + pub messages: Vec<NewsletterLiveUpdateMessage>, +} + +/// A single message entry in a newsletter live update. +#[derive(Debug, Clone, Serialize)] +pub struct NewsletterLiveUpdateMessage { + pub server_id: u64, + pub reactions: Vec<NewsletterLiveUpdateReaction>, +} + +/// A reaction count in a newsletter live update. +#[derive(Debug, Clone, Serialize)] +pub struct NewsletterLiveUpdateReaction { + pub code: String, + pub count: u64, } #[derive(Debug, Clone, Serialize)] From 2bf61e14cf89d2a538f15850eacabd82fc18ce1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <jlucaso@hotmail.com> Date: Wed, 18 Mar 2026 16:11:24 -0300 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20re-exports,=20DRY,=20backward=20compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-export NewsletterMessage and NewsletterReactionCount from crate root - Extract shared parse_reaction_counts() helper, used by both message history parser and notification handler (DRY) - Dispatch raw Event::Notification for newsletter notifications alongside Event::NewsletterLiveUpdate for backward compatibility (matches group handler pattern) - Document media upload limitation on send_message() --- src/features/mod.rs | 2 +- src/features/newsletter.rs | 55 +++++++++++-------- src/handlers/notification.rs | 102 +++++++++++++++-------------------- src/lib.rs | 8 +-- 4 files changed, 79 insertions(+), 88 deletions(-) diff --git a/src/features/mod.rs b/src/features/mod.rs index a4f3409bc..e8c5d1c60 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -4,7 +4,7 @@ mod chatstate; mod contacts; mod groups; mod mex; -mod newsletter; +pub(crate) mod newsletter; mod presence; mod profile; pub(crate) mod status; diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index ff19cb923..8a9d8ea6a 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -343,6 +343,10 @@ impl<'a> Newsletter<'a> { /// /// Newsletter messages are plaintext (no Signal E2E encryption). /// Returns the message ID assigned by the client. + /// + /// **Note:** This sends the raw protobuf as plaintext. For media messages + /// (images, videos, etc.), the media must be uploaded separately using the + /// newsletter-specific upload endpoint first. Text messages work directly. pub async fn send_message( &self, jid: &Jid, @@ -491,6 +495,33 @@ fn parse_newsletter_metadata(value: &serde_json::Value) -> Result<NewsletterMeta }) } +// ─── Shared parsing helpers ──────────────────────────────────────────── + +/// Parse reaction counts from a `<reactions>` node. +/// Used by both message history parsing and notification handling. +pub(crate) fn parse_reaction_counts(node: &Node) -> Vec<NewsletterReactionCount> { + let mut reactions = Vec::new(); + if let Some(reactions_node) = node.get_optional_child("reactions") + && let Some(children) = reactions_node.children() + { + for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") { + let code = r + .attrs + .get("code") + .map(|v| v.as_str().into_owned()) + .unwrap_or_default(); + let count = r + .attrs + .get("count") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + reactions.push(NewsletterReactionCount { code, count }); + } + } + reactions +} + // Node response parsing helpers /// Parse the IQ response for newsletter message history. @@ -549,29 +580,7 @@ fn parse_newsletter_messages_response( _ => None, }); - // Parse <reactions> counts - let mut reactions = Vec::new(); - if let Some(reactions_node) = msg_node.get_optional_child("reactions") - && let Some(reaction_children) = reactions_node.children() - { - for r in reaction_children - .iter() - .filter(|n| n.tag.as_ref() == "reaction") - { - let code = r - .attrs - .get("code") - .map(|v| v.as_str().into_owned()) - .unwrap_or_default(); - let count = r - .attrs - .get("count") - .map(|v| v.as_str()) - .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(0); - reactions.push(NewsletterReactionCount { code, count }); - } - } + let reactions = parse_reaction_counts(msg_node); result.push(NewsletterMessage { server_id, diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index 2e576e1d8..c93a77c6c 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -1126,77 +1126,59 @@ async fn handle_group_notification(client: &Arc<Client>, node: &Node) { /// </notification> /// ``` fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) { + use crate::features::newsletter::parse_reaction_counts; use wacore::types::events::{ NewsletterLiveUpdate, NewsletterLiveUpdateMessage, NewsletterLiveUpdateReaction, }; let newsletter_jid = node.attrs().jid("from"); - let Some(live_updates) = node.get_optional_child("live_updates") else { - debug!( - "Newsletter notification without <live_updates>, dispatching raw: {}", - wacore::xml::DisplayableNode(node) - ); - client - .core - .event_bus - .dispatch(&Event::Notification(node.clone())); - return; - }; - - let Some(messages_node) = live_updates.get_optional_child("messages") else { - return; - }; - - let mut messages = Vec::new(); - if let Some(children) = messages_node.children() { - for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") { - let server_id = msg_node - .attrs - .get("server_id") - .map(|v| v.as_str()) - .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(0); - - let mut reactions = Vec::new(); - if let Some(reactions_node) = msg_node.get_optional_child("reactions") - && let Some(reaction_children) = reactions_node.children() - { - for r in reaction_children - .iter() - .filter(|n| n.tag.as_ref() == "reaction") - { - let code = r - .attrs - .get("code") - .map(|v| v.as_str().into_owned()) - .unwrap_or_default(); - let count = r - .attrs - .get("count") - .map(|v| v.as_str()) - .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(0); - reactions.push(NewsletterLiveUpdateReaction { code, count }); + if let Some(live_updates) = node.get_optional_child("live_updates") + && let Some(messages_node) = live_updates.get_optional_child("messages") + && let Some(children) = messages_node.children() + { + let messages: Vec<_> = children + .iter() + .filter(|n| n.tag.as_ref() == "message") + .map(|msg_node| { + let server_id = msg_node + .attrs + .get("server_id") + .map(|v| v.as_str()) + .and_then(|s| s.parse::<u64>().ok()) + .unwrap_or(0); + + let reactions = parse_reaction_counts(msg_node) + .into_iter() + .map(|r| NewsletterLiveUpdateReaction { + code: r.code, + count: r.count, + }) + .collect(); + + NewsletterLiveUpdateMessage { + server_id, + reactions, } - } + }) + .collect(); - messages.push(NewsletterLiveUpdateMessage { - server_id, - reactions, - }); + if !messages.is_empty() { + client + .core + .event_bus + .dispatch(&Event::NewsletterLiveUpdate(NewsletterLiveUpdate { + newsletter_jid, + messages, + })); } } - if !messages.is_empty() { - client - .core - .event_bus - .dispatch(&Event::NewsletterLiveUpdate(NewsletterLiveUpdate { - newsletter_jid, - messages, - })); - } + // Also dispatch raw notification for backward compatibility + client + .core + .event_bus + .dispatch(&Event::Notification(node.clone())); } /// Handle `<notification type="disappearing_mode">` — a contact changed diff --git a/src/lib.rs b/src/lib.rs index 2f3b8355c..d8b5ee1c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,10 +50,10 @@ pub use features::{ CreateGroupResult, GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupSubject, Groups, IsOnWhatsAppResult, MemberAddMode, MemberLinkMode, MembershipApprovalMode, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, Newsletter, NewsletterMetadata, NewsletterRole, NewsletterState, - NewsletterVerification, ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, - Profile, ProfilePicture, SetProfilePictureResponse, Status, StatusPrivacySetting, - StatusSendOptions, TcToken, UserInfo, + MexResponse, Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, + NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, Presence, + PresenceError, PresenceStatus, Profile, ProfilePicture, SetProfilePictureResponse, Status, + StatusPrivacySetting, StatusSendOptions, TcToken, UserInfo, }; pub mod bot; From b378404c824ca3d7b13462fb75c1c63f07f92920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <jlucaso@hotmail.com> Date: Wed, 18 Mar 2026 16:21:39 -0300 Subject: [PATCH 7/8] fix: skip malformed nodes, tighten pagination assertion - Skip message nodes without valid server_id instead of defaulting to 0 (both in history parser and notification handler) - Tighten pagination test assertion from <= 4 to <= 2 (matches count=2) --- src/features/newsletter.rs | 7 +++++-- src/handlers/notification.rs | 9 ++++----- tests/e2e/tests/newsletter.rs | 5 +++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 8a9d8ea6a..28b857640 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -550,12 +550,15 @@ fn parse_newsletter_messages_response( let mut result = Vec::with_capacity(children.len()); for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") { - let server_id = msg_node + // Skip nodes without a valid server_id (required for pagination/correlation) + let Some(server_id) = msg_node .attrs .get("server_id") .map(|v| v.as_str()) .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(0); + else { + continue; + }; let timestamp = msg_node .attrs diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index c93a77c6c..bbdfb0617 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -1140,13 +1140,12 @@ fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) { let messages: Vec<_> = children .iter() .filter(|n| n.tag.as_ref() == "message") - .map(|msg_node| { + .filter_map(|msg_node| { let server_id = msg_node .attrs .get("server_id") .map(|v| v.as_str()) - .and_then(|s| s.parse::<u64>().ok()) - .unwrap_or(0); + .and_then(|s| s.parse::<u64>().ok())?; let reactions = parse_reaction_counts(msg_node) .into_iter() @@ -1156,10 +1155,10 @@ fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) { }) .collect(); - NewsletterLiveUpdateMessage { + Some(NewsletterLiveUpdateMessage { server_id, reactions, - } + }) }) .collect(); diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 2fdf4982b..6d630330c 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -291,8 +291,9 @@ async fn test_newsletter_message_pagination() -> anyhow::Result<()> { .await?; assert!( - page.len() <= 4, - "page before last should have fewer messages" + page.len() <= 2, + "page with count=2 should return at most 2 messages, got {}", + page.len() ); info!( From 74d8eea671d1f41cd896e86e9b149bec4a43c6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <jlucaso@hotmail.com> Date: Wed, 18 Mar 2026 16:34:49 -0300 Subject: [PATCH 8/8] fix: defensive newsletter notification parsing - Use optional_jid("from") with early return instead of jid("from") which returns Jid::default() on missing attrs (matches other handlers) - Skip reactions with empty code attribute instead of defaulting to "" --- src/features/newsletter.rs | 7 +++++-- src/handlers/notification.rs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 28b857640..4f1fc3843 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -505,11 +505,14 @@ pub(crate) fn parse_reaction_counts(node: &Node) -> Vec<NewsletterReactionCount> && let Some(children) = reactions_node.children() { for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") { - let code = r + let Some(code) = r .attrs .get("code") .map(|v| v.as_str().into_owned()) - .unwrap_or_default(); + .filter(|s| !s.is_empty()) + else { + continue; + }; let count = r .attrs .get("count") diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index bbdfb0617..373ba5d35 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -1131,7 +1131,9 @@ fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) { NewsletterLiveUpdate, NewsletterLiveUpdateMessage, NewsletterLiveUpdateReaction, }; - let newsletter_jid = node.attrs().jid("from"); + let Some(newsletter_jid) = node.attrs().optional_jid("from") else { + return; + }; if let Some(live_updates) = node.get_optional_child("live_updates") && let Some(messages_node) = live_updates.get_optional_child("messages")