From 6ec3f38061fa16d1f744fdc9c4516346218778b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 22 Jan 2026 11:25:32 -0300 Subject: [PATCH 01/18] feat: type-safe IQ protocol architecture with derive macros --- AGENTS.md | 339 +++++++++- Cargo.lock | 32 + Cargo.toml | 2 + src/features/blocking.rs | 99 +-- src/features/chatstate.rs | 53 +- src/features/contacts.rs | 421 +------------ src/features/groups.rs | 572 ++--------------- src/features/mex.rs | 150 +---- src/features/presence.rs | 38 +- src/mediaconn.rs | 56 +- src/prekeys.rs | 54 +- src/request.rs | 24 + src/spam_report.rs | 33 +- src/usync.rs | 26 +- wacore/Cargo.toml | 3 + wacore/derive/Cargo.toml | 16 + wacore/derive/src/lib.rs | 409 ++++++++++++ wacore/src/iq/blocklist.rs | 351 +++++++++++ wacore/src/iq/contacts.rs | 244 ++++++++ wacore/src/iq/groups.rs | 796 ++++++++++++++++++++++++ wacore/src/iq/keepalive.rs | 67 ++ wacore/src/iq/mediaconn.rs | 162 +++++ wacore/src/iq/mex.rs | 259 ++++++++ wacore/src/iq/mod.rs | 11 + wacore/src/iq/node.rs | 51 ++ wacore/src/iq/prekeys.rs | 217 +++++++ wacore/src/iq/spam_report.rs | 142 +++++ wacore/src/iq/spec.rs | 17 + wacore/src/iq/usync.rs | 1035 +++++++++++++++++++++++++++++++ wacore/src/lib.rs | 6 + wacore/src/pair_code.rs | 62 +- wacore/src/protocol.rs | 140 +++++ wacore/src/request.rs | 15 +- wacore/src/types/message.rs | 31 +- wacore/src/types/spam_report.rs | 34 +- 35 files changed, 4613 insertions(+), 1354 deletions(-) create mode 100644 wacore/derive/Cargo.toml create mode 100644 wacore/derive/src/lib.rs create mode 100644 wacore/src/iq/blocklist.rs create mode 100644 wacore/src/iq/contacts.rs create mode 100644 wacore/src/iq/groups.rs create mode 100644 wacore/src/iq/keepalive.rs create mode 100644 wacore/src/iq/mediaconn.rs create mode 100644 wacore/src/iq/mex.rs create mode 100644 wacore/src/iq/mod.rs create mode 100644 wacore/src/iq/node.rs create mode 100644 wacore/src/iq/prekeys.rs create mode 100644 wacore/src/iq/spam_report.rs create mode 100644 wacore/src/iq/spec.rs create mode 100644 wacore/src/iq/usync.rs create mode 100644 wacore/src/protocol.rs diff --git a/AGENTS.md b/AGENTS.md index 9d0a4c3f5..70ce2a444 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ You are an expert Rust developer specializing in asynchronous networking, crypto The project is split into three main crates: - **wacore** - A platform-agnostic library containing the pure, `no_std`-compatible core logic for the WhatsApp binary protocol, cryptography primitives, and state management traits. - It has **no dependencies** on Tokio or specific databases. + A platform-agnostic library containing core logic for the WhatsApp binary protocol, cryptography primitives, IQ protocol types, and state management traits. + It has **no runtime dependencies** on Tokio or specific databases. - **waproto** Houses the Protocol Buffers definitions (`whatsapp.proto`). It contains a `build.rs` script that uses **prost** to compile these definitions into Rust structs. @@ -22,7 +22,7 @@ The project is split into three main crates: - **Client** (`src/client.rs`): Orchestrates the connection lifecycle, event bus, and high-level operations. - **PersistenceManager** (`src/store/persistence_manager.rs`): Manages all state. -- **Signal Protocol** (`wacore/src/signal/` & `src/store/signal*.rs`): E2E encryption via our Signal Protocol implementation. +- **Signal Protocol** (`wacore/libsignal/` & `src/store/signal*.rs`): E2E encryption via our Signal Protocol implementation. - **Socket & Handshake** (`src/socket/`, `src/handshake.rs`): Handles WebSocket connection and Noise Protocol handshake. --- @@ -79,7 +79,10 @@ The project is split into three main crates: - `src/download.rs`: Media download logic. - `src/upload.rs`: Media upload logic. - `src/mediaconn.rs`: Media server connection management. +- `src/features/`: High-level feature APIs (groups, blocking, etc.). +- `wacore/src/iq/`: Type-safe IQ protocol types and specs. - `waproto/src/whatsapp.proto`: Source of all message structures. +- `docs/captured-js/`: Captured WhatsApp Web JavaScript for reverse engineering. --- @@ -128,17 +131,343 @@ When adding a new feature, follow a repeatable flow that mirrors WhatsApp Web be --- -## 6. Final Implementation Checks +## 6. Type-Safe Protocol Node Architecture + +All protocol stanza builders should use the declarative, type-safe pattern defined in `wacore/src/iq/`. This architecture provides compile-time safety, validation, and clear separation between request building and response parsing. + +### Core Traits + +#### `ProtocolNode` (`wacore/src/protocol.rs`) + +Maps Rust structs to WhatsApp protocol nodes: + +```rust +pub trait ProtocolNode: Sized { + fn tag(&self) -> &'static str; + fn into_node(self) -> Node; + fn try_from_node(node: &Node) -> Result; +} +``` + +#### `IqSpec` (`wacore/src/iq/spec.rs`) + +Pairs IQ requests with their typed responses: + +```rust +pub trait IqSpec { + type Response; + fn build_iq(&self) -> InfoQuery<'static>; + fn parse_response(&self, response: &Node) -> Result; +} +``` + +### Implementation Pattern + +1. **Define request struct with `ProtocolNode`**: + +```rust +#[derive(Debug, Clone)] +pub struct GroupQueryRequest { + pub request_type: String, +} + +impl ProtocolNode for GroupQueryRequest { + fn tag(&self) -> &'static str { "query" } + fn into_node(self) -> Node { + NodeBuilder::new("query") + .attr("request", &self.request_type) + .build() + } + fn try_from_node(node: &Node) -> Result { /* ... */ } +} +``` + +2. **Define response struct with `ProtocolNode`**: + +```rust +pub struct GroupInfoResponse { + pub id: Jid, + pub subject: GroupSubject, + pub addressing_mode: AddressingMode, + pub participants: Vec, +} + +impl ProtocolNode for GroupInfoResponse { + fn tag(&self) -> &'static str { "group" } + fn try_from_node(node: &Node) -> Result { /* parse from XML */ } + fn into_node(self) -> Node { /* ... */ } +} +``` + +3. **Create IqSpec implementation**: + +```rust +pub struct GroupQueryIq { + group_jid: Jid, +} + +impl GroupQueryIq { + pub fn new(group_jid: &Jid) -> Self { + Self { group_jid: group_jid.clone() } + } +} + +impl IqSpec for GroupQueryIq { + type Response = GroupInfoResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get( + GROUP_IQ_NAMESPACE, + self.group_jid.clone(), + Some(NodeContent::Nodes(vec![ + GroupQueryRequest::default().into_node() + ])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + GroupInfoResponse::try_from_node(response) + } +} +``` + +4. **Use in feature code** (`src/features/`): + +```rust +// Use client.execute() for simplified IQ handling +let group_response = self.client.execute(GroupQueryIq::new(&jid)).await?; +``` + +### Validated Newtypes + +Use newtypes to enforce protocol constraints at compile time: + +```rust +/// Group subject with WhatsApp's 100 character limit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupSubject(String); + +impl GroupSubject { + pub fn new(subject: impl Into) -> Result { + let s = subject.into(); + if s.len() > GROUP_SUBJECT_MAX_LENGTH { + return Err(anyhow!("subject exceeds {} chars", GROUP_SUBJECT_MAX_LENGTH)); + } + Ok(Self(s)) + } +} +``` + +Constants from WhatsApp Web A/B props (`wacore/src/iq/groups.rs`): +- `GROUP_SUBJECT_MAX_LENGTH`: 100 characters +- `GROUP_DESCRIPTION_MAX_LENGTH`: 512 characters +- `GROUP_SIZE_LIMIT`: 257 participants + +### Strongly Typed Enums + +Replace stringly-typed attributes with enums using the `StringEnum` derive macro: + +```rust +use wacore::StringEnum; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum MemberAddMode { + #[str = "admin_add"] + AdminAdd, + #[str = "all_member_add"] + AllMemberAdd, +} + +// Automatically generates: +// - as_str() -> &'static str +// - Display impl +// - TryFrom<&str> impl +// - Default impl (first variant, or use #[string_default]) +``` + +For enums where the default should not be the first variant: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum MembershipApprovalMode { + #[string_default] // Mark this as default + #[str = "off"] + Off, + #[str = "on"] + On, +} +``` + +### Derive Macros (Recommended) + +For simple nodes and enums, use the derive macros from `wacore-derive` (re-exported via `wacore`): + +```rust +use wacore::{ProtocolNode, EmptyNode, StringEnum}; + +// Empty node (tag only) +#[derive(EmptyNode)] +#[protocol(tag = "participants")] +pub struct ParticipantsRequest; + +// Node with string attributes +#[derive(ProtocolNode)] +#[protocol(tag = "query")] +pub struct QueryRequest { + #[attr(name = "request", default = "interactive")] + pub request_type: String, +} + +// Enum with string representations +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum BlocklistAction { + #[str = "block"] + Block, + #[str = "unblock"] + Unblock, +} +``` + +**Available derive macros:** +- `EmptyNode` - For nodes with only a tag (no attributes) +- `ProtocolNode` - For nodes with string attributes +- `StringEnum` - For enums with string representations (generates `as_str()`, `Display`, `TryFrom<&str>`, `Default`) + +**Benefits over manual implementations:** +- Better IDE support (autocomplete, go-to-definition) +- Clearer error messages from the compiler +- Standard Rust derive pattern +- Less boilerplate code + +### Declarative Macros (Legacy) + +> **Note**: Prefer derive macros (`EmptyNode`, `ProtocolNode`, `StringEnum`) for new code. + +For quick one-off definitions, declarative macros in `wacore/src/protocol.rs` are also available: + +```rust +// Empty node +define_empty_node!( + /// Wire format: `` + pub struct ParticipantsRequest("participants") +); + +// Node with attributes +define_simple_node! { + /// Wire format: `` + pub struct QueryRequest("query") { + #[attr("request")] + pub request_type: String = "interactive", + } +} +``` + +### Generic IQ Executor + +Use `Client::execute()` for simplified IQ request/response handling: + +```rust +// Before: manual build + send + parse +let spec = GroupQueryIq::new(&jid); +let resp_node = client.send_iq(spec.build_iq()).await?; +let response = spec.parse_response(&resp_node)?; + +// After: single execute() call +let response = client.execute(GroupQueryIq::new(&jid)).await?; +``` + +**API Design Note**: IqSpec constructors should take `&Jid` instead of `Jid` to avoid forcing callers to clone. The clone happens inside the constructor: + +```rust +impl UpdateBlocklistSpec { + pub fn block(jid: &Jid) -> Self { + Self { request: BlocklistItemRequest::block(jid) } + } +} + +// Caller doesn't need to clone +client.execute(UpdateBlocklistSpec::block(&jid)).await?; +``` + +### File Organization + +``` +wacore/src/iq/ +├── mod.rs # Re-exports +├── spec.rs # IqSpec trait definition +├── node.rs # Helper functions (required_child, required_attr, optional_attr) +├── groups.rs # Group types, enums, newtypes, ProtocolNode & IqSpec impls +└── blocklist.rs # Blocklist types, ProtocolNode & IqSpec impls +``` + +Each feature file (e.g., `groups.rs`, `blocklist.rs`) contains: +- Constants (namespaces, limits) +- Enums with `StringEnum` derive +- Request/Response structs with `ProtocolNode` impl +- `IqSpec` implementations pairing requests with responses +- Unit tests + +### Node Parsing Helpers + +Use helper functions from `wacore/src/iq/node.rs` for consistent parsing: + +```rust +use crate::iq::node::{required_child, required_attr, optional_attr, optional_jid}; + +fn try_from_node(node: &Node) -> Result { + let id = required_attr(node, "id")?; // Error if missing + let name = optional_attr(node, "name"); // Returns Option<&str> + let jid = optional_jid(node, "jid")?; // Returns Result> + let child = required_child(node, "group")?; // Error if missing + // ... +} +``` + +### Benefits + +| Aspect | Before (Imperative) | After (Type-Safe) | +|--------|---------------------|-------------------| +| Attribute names | Raw strings, typo-prone | Compile-time checked | +| Validation | Runtime, easy to forget | Enforced via newtypes | +| Request/Response | Disconnected functions | Paired via `IqSpec` | +| Wire format | Scattered in builders | Documented on types | +| Refactoring | Find-and-replace | Compiler-assisted | + +--- + +## 7. Reverse Engineering Reference + +The `docs/captured-js/` directory contains captured WhatsApp Web JavaScript files. Use these to verify protocol implementations: + +```bash +# Search for blocklist-related code +grep -r "blocklist" docs/captured-js/*.js + +# Find specific IQ namespace usage +grep -r "xmlns.*blocklist\|xmlns.*w:g2" docs/captured-js/*.js +``` + +**Key patterns to look for:** +- `xmlns: "namespace"` - IQ namespaces +- `action: "value"` - Action attributes +- `smax("tag", { attrs })` - Node construction +- Module names like `WASmaxOutBlocklists*` - Outgoing request builders +- Module names like `WASmaxInBlocklists*` - Incoming response parsers + +--- + +## 8. Final Implementation Checks Before finalizing a feature/fix, always run: - **Format**: `cargo fmt` - **Lint**: `cargo clippy --all-targets` - **Test**: `cargo test --all` +- **Review**: `coderabbit review --prompt-only` (if available) --- -## 7. Debugging Tools +## 9. Debugging Tools ### evcxr - Rust REPL diff --git a/Cargo.lock b/Cargo.lock index 9d1a95402..541132149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2098,6 +2098,26 @@ dependencies = [ "winnow", ] +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2244,10 +2264,13 @@ dependencies = [ "rand_core 0.9.5", "serde", "serde-big-array", + "serde_json", "sha2", "thiserror 2.0.18", + "typed-builder", "wacore-appstate", "wacore-binary", + "wacore-derive", "wacore-libsignal", "wacore-noise", "waproto", @@ -2283,6 +2306,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "wacore-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wacore-libsignal" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 6fd923663..0e0ff4531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "wacore", "wacore/appstate", "wacore/binary", + "wacore/derive", "wacore/libsignal", "wacore/noise", "waproto", @@ -56,6 +57,7 @@ tokio = { version = "1.48.0", default-features = false } wacore = { path = "./wacore", version = "0.2.0" } wacore-appstate = { path = "./wacore/appstate", version = "0.2.0" } wacore-binary = { path = "./wacore/binary", version = "0.2.0" } +wacore-derive = { path = "./wacore/derive", version = "0.2.0" } wacore-libsignal = { path = "./wacore/libsignal", version = "0.2.0" } wacore-noise = { path = "./wacore/noise", version = "0.2.0" } waproto = { path = "./waproto", version = "0.2.0" } diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 1f3aa6b48..42cd1c0ed 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -1,18 +1,17 @@ +//! Blocking feature for managing blocked contacts. +//! +//! This module provides high-level APIs for blocking and unblocking contacts. +//! Protocol-level types are defined in `wacore::iq::blocklist`. + use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::{InfoQuery, IqError}; +use crate::request::IqError; use anyhow::Result; use log::debug; -use wacore_binary::builder::NodeBuilder; +pub use wacore::iq::blocklist::BlocklistEntry; +use wacore::iq::blocklist::{GetBlocklistSpec, UpdateBlocklistSpec}; use wacore_binary::jid::Jid; -use wacore_binary::node::NodeContent; - -#[derive(Debug, Clone)] -pub struct BlocklistEntry { - pub jid: Jid, - pub timestamp: Option, -} +/// Feature handle for blocklist operations. pub struct Blocking<'a> { client: &'a Client, } @@ -22,67 +21,31 @@ impl<'a> Blocking<'a> { Self { client } } + /// Block a contact. pub async fn block(&self, jid: &Jid) -> Result<(), IqError> { debug!(target: "Blocking", "Blocking contact: {}", jid); - self.update_blocklist(jid, "block").await + self.client.execute(UpdateBlocklistSpec::block(jid)).await?; + debug!(target: "Blocking", "Successfully blocked contact: {}", jid); + Ok(()) } + /// Unblock a contact. pub async fn unblock(&self, jid: &Jid) -> Result<(), IqError> { debug!(target: "Blocking", "Unblocking contact: {}", jid); - self.update_blocklist(jid, "unblock").await + self.client.execute(UpdateBlocklistSpec::unblock(jid)).await?; + debug!(target: "Blocking", "Successfully unblocked contact: {}", jid); + Ok(()) } + /// Get the full blocklist. pub async fn get_blocklist(&self) -> Result> { debug!(target: "Blocking", "Fetching blocklist..."); - - let iq = InfoQuery::get("blocklist", server_jid(), None); - - let response = self.client.send_iq(iq).await?; - self.parse_blocklist_response(&response) - } - - async fn update_blocklist(&self, jid: &Jid, action: &str) -> Result<(), IqError> { - let item_node = NodeBuilder::new("item") - .attr("action", action) - .attr("jid", jid.to_string()) - .build(); - - let iq = InfoQuery::set( - "blocklist", - server_jid(), - Some(NodeContent::Nodes(vec![item_node])), - ); - - self.client.send_iq(iq).await?; - debug!(target: "Blocking", "Successfully {}ed contact: {}", action, jid); - Ok(()) - } - - fn parse_blocklist_response( - &self, - node: &wacore_binary::node::Node, - ) -> Result> { - let mut entries = Vec::new(); - - let items = if let Some(list) = node.get_optional_child("list") { - list.get_children_by_tag("item") - } else { - node.get_children_by_tag("item") - }; - - for item in items { - if let Some(jid_str) = item.attrs().optional_string("jid") - && let Ok(jid) = jid_str.parse::() - { - let timestamp = item.attrs().optional_u64("t"); - entries.push(BlocklistEntry { jid, timestamp }); - } - } - - debug!(target: "Blocking", "Parsed {} blocked contacts", entries.len()); + let entries = self.client.execute(GetBlocklistSpec).await?; + debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len()); Ok(entries) } + /// Check if a contact is blocked. pub async fn is_blocked(&self, jid: &Jid) -> Result { let blocklist = self.get_blocklist().await?; Ok(blocklist.iter().any(|e| e.jid.user == jid.user)) @@ -90,26 +53,8 @@ impl<'a> Blocking<'a> { } impl Client { + /// Access blocking operations. pub fn blocking(&self) -> Blocking<'_> { Blocking::new(self) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_blocklist_entry() { - let jid: Jid = "1234567890@s.whatsapp.net" - .parse() - .expect("test JID should be valid"); - let entry = BlocklistEntry { - jid: jid.clone(), - timestamp: Some(1234567890), - }; - - assert_eq!(entry.jid.user, "1234567890"); - assert_eq!(entry.timestamp, Some(1234567890)); - } -} diff --git a/src/features/chatstate.rs b/src/features/chatstate.rs index 79fd88714..3d2e236a0 100644 --- a/src/features/chatstate.rs +++ b/src/features/chatstate.rs @@ -1,33 +1,26 @@ +//! Chat state (typing indicators) feature. + use crate::client::Client; use log::debug; +use wacore::StringEnum; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::Jid; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Chat state type for typing indicators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum ChatStateType { + /// User is typing a text message. + #[str = "composing"] Composing, - + /// User is recording an audio message. + #[str = "recording"] Recording, - + /// User has stopped typing. + #[str = "paused"] Paused, } -impl ChatStateType { - fn as_str(&self) -> &'static str { - match self { - ChatStateType::Composing => "composing", - ChatStateType::Recording => "recording", - ChatStateType::Paused => "paused", - } - } -} - -impl std::fmt::Display for ChatStateType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - +/// Feature handle for chat state operations. pub struct Chatstate<'a> { client: &'a Client, } @@ -37,6 +30,7 @@ impl<'a> Chatstate<'a> { Self { client } } + /// Send a chat state update to a recipient. pub async fn send( &self, to: &Jid, @@ -48,14 +42,17 @@ impl<'a> Chatstate<'a> { self.client.send_node(node).await } + /// Send "composing" (typing) state. pub async fn send_composing(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Composing).await } + /// Send "recording" (voice message) state. pub async fn send_recording(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Recording).await } + /// Send "paused" (stopped typing) state. pub async fn send_paused(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Paused).await } @@ -77,6 +74,7 @@ impl<'a> Chatstate<'a> { } impl Client { + /// Access chat state operations. pub fn chatstate(&self) -> Chatstate<'_> { Chatstate::new(self) } @@ -87,16 +85,13 @@ mod tests { use super::*; #[test] - fn test_chat_state_type_display() { - assert_eq!(ChatStateType::Composing.to_string(), "composing"); - assert_eq!(ChatStateType::Recording.to_string(), "recording"); - assert_eq!(ChatStateType::Paused.to_string(), "paused"); - } - - #[test] - fn test_chat_state_type_as_str() { + fn test_chat_state_type_string_enum() { + // Verify StringEnum derive works correctly assert_eq!(ChatStateType::Composing.as_str(), "composing"); - assert_eq!(ChatStateType::Recording.as_str(), "recording"); - assert_eq!(ChatStateType::Paused.as_str(), "paused"); + assert_eq!(ChatStateType::Recording.to_string(), "recording"); + assert_eq!( + ChatStateType::try_from("paused").unwrap(), + ChatStateType::Paused + ); } } diff --git a/src/features/contacts.rs b/src/features/contacts.rs index b3a83a714..e40ed0930 100644 --- a/src/features/contacts.rs +++ b/src/features/contacts.rs @@ -1,55 +1,19 @@ +//! Contact information feature. +//! +//! Profile picture types are defined in `wacore::iq::contacts`. +//! Usync types are defined in `wacore::iq::usync`. + use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::InfoQuery; -use anyhow::{Result, anyhow}; +use anyhow::Result; use log::debug; use std::collections::HashMap; -use wacore_binary::builder::NodeBuilder; +use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType}; +use wacore::iq::usync::{ContactInfoSpec, IsOnWhatsAppSpec, UserInfoSpec}; use wacore_binary::jid::Jid; -use wacore_binary::node::{Node, NodeContent}; - -#[derive(Debug, Clone)] -pub struct IsOnWhatsAppResult { - pub jid: Jid, - pub is_registered: bool, -} - -#[derive(Debug, Clone)] -pub struct ContactInfo { - pub jid: Jid, - - pub lid: Option, - - pub is_registered: bool, - - pub is_business: bool, - - pub status: Option, - - pub picture_id: Option, -} - -#[derive(Debug, Clone)] -pub struct ProfilePicture { - pub id: String, - pub url: String, - - pub direct_path: Option, -} - -#[derive(Debug, Clone)] -pub struct UserInfo { - pub jid: Jid, - - pub lid: Option, - - pub status: Option, - - pub picture_id: Option, - - pub is_business: bool, -} +// Re-export types from wacore +pub use wacore::iq::contacts::ProfilePicture; +pub use wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo}; pub struct Contacts<'a> { client: &'a Client, @@ -65,50 +29,13 @@ impl<'a> Contacts<'a> { return Ok(Vec::new()); } - let request_id = self.client.generate_request_id(); debug!("is_on_whatsapp: checking {} numbers", phones.len()); - let query_node = NodeBuilder::new("query") - .children(vec![NodeBuilder::new("contact").build()]) - .build(); - - let user_nodes: Vec = phones - .iter() - .map(|phone| { - let phone_content = if phone.starts_with('+') { - phone.to_string() - } else { - format!("+{}", phone) - }; - NodeBuilder::new("user") - .children(vec![ - NodeBuilder::new("contact") - .string_content(phone_content) - .build(), - ]) - .build() - }) - .collect(); - - let list_node = NodeBuilder::new("list").children(user_nodes).build(); - - let usync_node = NodeBuilder::new("usync") - .attr("sid", request_id.as_str()) - .attr("mode", "query") - .attr("last", "true") - .attr("index", "0") - .attr("context", "interactive") - .children(vec![query_node, list_node]) - .build(); - - let iq = InfoQuery::get( - "usync", - server_jid(), - Some(NodeContent::Nodes(vec![usync_node])), - ); + let request_id = self.client.generate_request_id(); + let phone_strings: Vec = phones.iter().map(|s| s.to_string()).collect(); + let spec = IsOnWhatsAppSpec::new(phone_strings, request_id); - let response_node = self.client.send_iq(iq).await?; - Self::parse_is_on_whatsapp_response(&response_node) + Ok(self.client.execute(spec).await?) } pub async fn get_info(&self, phones: &[&str]) -> Result> { @@ -116,56 +43,13 @@ impl<'a> Contacts<'a> { return Ok(Vec::new()); } - let request_id = self.client.generate_request_id(); debug!("get_info: fetching info for {} numbers", phones.len()); - let query_node = NodeBuilder::new("query") - .children(vec![ - NodeBuilder::new("contact").build(), - NodeBuilder::new("lid").build(), - NodeBuilder::new("status").build(), - NodeBuilder::new("picture").build(), - NodeBuilder::new("business").build(), - ]) - .build(); - - let user_nodes: Vec = phones - .iter() - .map(|phone| { - let phone_content = if phone.starts_with('+') { - phone.to_string() - } else { - format!("+{}", phone) - }; - NodeBuilder::new("user") - .children(vec![ - NodeBuilder::new("contact") - .string_content(phone_content) - .build(), - ]) - .build() - }) - .collect(); - - let list_node = NodeBuilder::new("list").children(user_nodes).build(); - - let usync_node = NodeBuilder::new("usync") - .attr("sid", request_id.as_str()) - .attr("mode", "query") - .attr("last", "true") - .attr("index", "0") - .attr("context", "interactive") - .children(vec![query_node, list_node]) - .build(); - - let iq = InfoQuery::get( - "usync", - server_jid(), - Some(NodeContent::Nodes(vec![usync_node])), - ); + let request_id = self.client.generate_request_id(); + let phone_strings: Vec = phones.iter().map(|s| s.to_string()).collect(); + let spec = ContactInfoSpec::new(phone_strings, request_id); - let response_node = self.client.send_iq(iq).await?; - Self::parse_contact_info_response(&response_node) + Ok(self.client.execute(spec).await?) } pub async fn get_profile_picture( @@ -179,21 +63,14 @@ impl<'a> Contacts<'a> { jid ); - let picture_type = if preview { "preview" } else { "image" }; - let picture_node = NodeBuilder::new("picture") - .attr("type", picture_type) - .attr("query", "url") - .build(); - - let iq = InfoQuery::get( - "w:profile:picture", - server_jid(), - Some(NodeContent::Nodes(vec![picture_node])), - ) - .with_target(jid.clone()); + let picture_type = if preview { + ProfilePictureType::Preview + } else { + ProfilePictureType::Full + }; + let spec = ProfilePictureSpec::new(jid, picture_type); - let response_node = self.client.send_iq(iq).await?; - Self::parse_profile_picture_response(&response_node) + Ok(self.client.execute(spec).await?) } pub async fn get_user_info(&self, jids: &[Jid]) -> Result> { @@ -201,252 +78,12 @@ impl<'a> Contacts<'a> { return Ok(HashMap::new()); } - let request_id = self.client.generate_request_id(); debug!("get_user_info: fetching info for {} JIDs", jids.len()); - let query_node = NodeBuilder::new("query") - .children(vec![ - NodeBuilder::new("business") - .children(vec![NodeBuilder::new("verified_name").build()]) - .build(), - NodeBuilder::new("status").build(), - NodeBuilder::new("picture").build(), - NodeBuilder::new("devices").attr("version", "2").build(), - NodeBuilder::new("lid").build(), - ]) - .build(); - - let user_nodes: Vec = jids - .iter() - .map(|jid| { - NodeBuilder::new("user") - .attr("jid", jid.to_non_ad().to_string()) - .build() - }) - .collect(); - - let list_node = NodeBuilder::new("list").children(user_nodes).build(); - - let usync_node = NodeBuilder::new("usync") - .attr("sid", request_id.as_str()) - .attr("mode", "full") - .attr("last", "true") - .attr("index", "0") - .attr("context", "background") - .children(vec![query_node, list_node]) - .build(); - - let iq = InfoQuery::get( - "usync", - server_jid(), - Some(NodeContent::Nodes(vec![usync_node])), - ); - - let response_node = self.client.send_iq(iq).await?; - Self::parse_user_info_response(&response_node) - } - - fn parse_is_on_whatsapp_response(node: &Node) -> Result> { - let usync = node - .get_optional_child("usync") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let list = usync - .get_optional_child("list") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let mut results = Vec::new(); - - for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let contact_node = user_node.get_optional_child("contact"); - let is_registered = contact_node - .map(|c| c.attrs().optional_string("type") == Some("in")) - .unwrap_or(false); - - results.push(IsOnWhatsAppResult { jid, is_registered }); - } - } - - Ok(results) - } - - fn parse_contact_info_response(node: &Node) -> Result> { - let usync = node - .get_optional_child("usync") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let list = usync - .get_optional_child("list") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let mut results = Vec::new(); - - for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let contact_node = user_node.get_optional_child("contact"); - let is_registered = contact_node - .map(|c| c.attrs().optional_string("type") == Some("in")) - .unwrap_or(false); - - let lid = user_node.get_optional_child("lid").and_then(|lid_node| { - lid_node - .attrs() - .optional_string("val") - .and_then(|val| val.parse::().ok()) - }); - - let status = user_node - .get_optional_child("status") - .and_then(|status_node| { - if status_node.get_optional_child("error").is_some() { - return None; - } - match &status_node.content { - Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), - _ => None, - } - }); - - let picture_id = user_node - .get_optional_child("picture") - .and_then(|pic_node| { - if pic_node.get_optional_child("error").is_some() { - return None; - } - pic_node.attrs().optional_u64("id") - }); - - let is_business = user_node.get_optional_child("business").is_some(); - - results.push(ContactInfo { - jid, - lid, - is_registered, - is_business, - status, - picture_id, - }); - } - } - - Ok(results) - } - - fn parse_profile_picture_response(node: &Node) -> Result> { - let picture_node = match node.get_optional_child("picture") { - Some(p) => p, - None => return Ok(None), - }; - - if let Some(error_node) = picture_node.get_optional_child("error") { - let code = error_node.attrs().optional_string("code").unwrap_or("0"); - if code == "404" || code == "401" { - return Ok(None); - } - let text = error_node - .attrs() - .optional_string("text") - .unwrap_or("unknown error"); - return Err(anyhow!("Profile picture error {}: {}", code, text)); - } - - let id = picture_node - .attrs() - .optional_string("id") - .map(|s| s.to_string()) - .unwrap_or_default(); - - let url = picture_node - .attrs() - .optional_string("url") - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("Picture response missing 'url' attribute"))?; - - let direct_path = picture_node - .attrs() - .optional_string("direct_path") - .map(|s| s.to_string()); - - Ok(Some(ProfilePicture { - id, - url, - direct_path, - })) - } - - fn parse_user_info_response(node: &Node) -> Result> { - let usync = node - .get_optional_child("usync") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let list = usync - .get_optional_child("list") - .ok_or_else(|| anyhow!("Response missing node"))?; - - let mut results = HashMap::new(); - - for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let lid = user_node.get_optional_child("lid").and_then(|lid_node| { - lid_node - .attrs() - .optional_string("val") - .and_then(|val| val.parse::().ok()) - }); - - let status = user_node - .get_optional_child("status") - .and_then(|status_node| { - if status_node.get_optional_child("error").is_some() { - return None; - } - match &status_node.content { - Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), - _ => None, - } - }); - - let picture_id = user_node - .get_optional_child("picture") - .and_then(|pic_node| { - if pic_node.get_optional_child("error").is_some() { - return None; - } - pic_node - .attrs() - .optional_string("id") - .map(|s| s.to_string()) - }); - - let is_business = user_node.get_optional_child("business").is_some(); - - results.insert( - jid.clone(), - UserInfo { - jid, - lid, - status, - picture_id, - is_business, - }, - ); - } - } + let request_id = self.client.generate_request_id(); + let spec = UserInfoSpec::new(jids.to_vec(), request_id); - Ok(results) + Ok(self.client.execute(spec).await?) } } diff --git a/src/features/groups.rs b/src/features/groups.rs index c321aa7e3..318978983 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -1,20 +1,24 @@ use crate::client::Client; -use crate::request::InfoQuery; use std::collections::HashMap; -use std::sync::LazyLock; use wacore::client::context::GroupInfo; -use wacore_binary::builder::NodeBuilder; -use wacore_binary::jid::{GROUP_SERVER, Jid}; -use wacore_binary::node::{Node, NodeContent}; - -static G_US_JID: LazyLock = LazyLock::new(|| Jid::new("", GROUP_SERVER)); +use wacore::iq::groups::{ + GroupCreateIq, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, + normalize_participants, +}; +use wacore::types::message::AddressingMode; +use wacore_binary::jid::Jid; + +pub use wacore::iq::groups::{ + GroupCreateOptions, GroupParticipantOptions, MemberAddMode, MemberLinkMode, + MembershipApprovalMode, +}; #[derive(Debug, Clone)] pub struct GroupMetadata { pub id: Jid, pub subject: String, pub participants: Vec, - pub addressing_mode: crate::types::message::AddressingMode, + pub addressing_mode: AddressingMode, } #[derive(Debug, Clone)] @@ -24,144 +28,14 @@ pub struct GroupParticipant { pub is_admin: bool, } -#[derive(Debug, Clone, Copy)] -pub enum MemberLinkMode { - AdminLink, - AllMemberLink, -} - -impl MemberLinkMode { - pub fn as_str(&self) -> &'static str { - match self { - MemberLinkMode::AdminLink => "admin_link", - MemberLinkMode::AllMemberLink => "all_member_link", - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum MemberAddMode { - AdminAdd, - AllMemberAdd, -} - -impl MemberAddMode { - pub fn as_str(&self) -> &'static str { - match self { - MemberAddMode::AdminAdd => "admin_add", - MemberAddMode::AllMemberAdd => "all_member_add", - } - } -} - -#[derive(Debug, Clone, Copy, Default)] -pub enum MembershipApprovalMode { - #[default] - Off, - On, -} - -impl MembershipApprovalMode { - pub fn as_str(&self) -> &'static str { - match self { - MembershipApprovalMode::Off => "off", - MembershipApprovalMode::On => "on", - } - } -} - -#[derive(Debug, Clone)] -pub struct GroupParticipantOptions { - pub jid: Jid, - pub phone_number: Option, - pub privacy: Option>, -} - -impl GroupParticipantOptions { - pub fn new(jid: Jid) -> Self { +impl From for GroupParticipant { + fn from(p: GroupParticipantResponse) -> Self { Self { - jid, - phone_number: None, - privacy: None, + jid: p.jid, + phone_number: p.phone_number, + is_admin: p.participant_type.is_admin(), } } - - pub fn with_phone_number(mut self, phone_number: Jid) -> Self { - self.phone_number = Some(phone_number); - self - } - - pub fn from_lid_and_phone(lid: Jid, phone_number: Jid) -> Self { - Self::new(lid).with_phone_number(phone_number) - } - - pub fn from_phone(phone_number: Jid) -> Self { - Self::new(phone_number) - } - - pub fn with_privacy(mut self, privacy: Vec) -> Self { - self.privacy = Some(privacy); - self - } -} - -#[derive(Debug, Clone)] -pub struct GroupCreateOptions { - pub subject: String, - pub participants: Vec, - pub member_link_mode: Option, - pub member_add_mode: Option, - pub membership_approval_mode: Option, - pub ephemeral_expiration: Option, -} - -impl GroupCreateOptions { - pub fn new(subject: impl Into) -> Self { - Self { - subject: subject.into(), - participants: Vec::new(), - member_link_mode: Some(MemberLinkMode::AdminLink), - member_add_mode: Some(MemberAddMode::AllMemberAdd), - membership_approval_mode: Some(MembershipApprovalMode::Off), - ephemeral_expiration: Some(0), - } - } - - pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self { - self.participants.push(participant); - self - } - - pub fn with_participants(mut self, participants: Vec) -> Self { - self.participants = participants; - self - } - - pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self { - self.member_link_mode = Some(mode); - self - } - - pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self { - self.member_add_mode = Some(mode); - self - } - - pub fn with_membership_approval_mode(mut self, mode: MembershipApprovalMode) -> Self { - self.membership_approval_mode = Some(mode); - self - } - - pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self { - self.ephemeral_expiration = Some(expiration); - self - } -} - -impl Default for GroupCreateOptions { - fn default() -> Self { - Self::new("") - } } #[derive(Debug, Clone)] @@ -169,93 +43,6 @@ pub struct CreateGroupResult { pub gid: Jid, } -fn normalize_participants( - participants: &[GroupParticipantOptions], -) -> Vec { - participants - .iter() - .cloned() - .map(|participant| { - if !participant.jid.is_lid() && participant.phone_number.is_some() { - GroupParticipantOptions { - phone_number: None, - ..participant - } - } else { - participant - } - }) - .collect() -} - -fn build_create_group_node(options: &GroupCreateOptions) -> Node { - let mut children = Vec::new(); - - if let Some(link_mode) = &options.member_link_mode { - children.push( - NodeBuilder::new("member_link_mode") - .string_content(link_mode.as_str()) - .build(), - ); - } - - if let Some(add_mode) = &options.member_add_mode { - children.push( - NodeBuilder::new("member_add_mode") - .string_content(add_mode.as_str()) - .build(), - ); - } - - for participant in &options.participants { - let mut participant_attrs = Vec::new(); - participant_attrs.push(("jid", participant.jid.to_string())); - - if let Some(phone_number) = &participant.phone_number { - participant_attrs.push(("phone_number", phone_number.to_string())); - } - - let participant_node = if let Some(privacy_bytes) = &participant.privacy { - let privacy_hex = hex::encode(privacy_bytes); - NodeBuilder::new("participant") - .attrs(participant_attrs) - .children([NodeBuilder::new("privacy") - .string_content(&privacy_hex) - .build()]) - .build() - } else { - NodeBuilder::new("participant") - .attrs(participant_attrs) - .build() - }; - - children.push(participant_node); - } - - if let Some(expiration) = &options.ephemeral_expiration { - children.push( - NodeBuilder::new("ephemeral") - .attr("expiration", expiration.to_string()) - .build(), - ); - } - - if let Some(approval_mode) = &options.membership_approval_mode { - children.push( - NodeBuilder::new("membership_approval_mode") - .children([NodeBuilder::new("group_join") - .attr("state", approval_mode.as_str()) - .build()]) - .build(), - ); - } - - NodeBuilder::new("create") - .attr("subject", &options.subject) - .children(children) - .build() -} - pub struct Groups<'a> { client: &'a Client, } @@ -270,46 +57,21 @@ impl<'a> Groups<'a> { return Ok(cached); } - let query_node = NodeBuilder::new("query") - .attr("request", "interactive") - .build(); - - let iq = InfoQuery::get( - "w:g2", - jid.clone(), - Some(NodeContent::Nodes(vec![query_node])), - ); - - let resp_node = self.client.send_iq(iq).await?; + let group = self.client.execute(GroupQueryIq::new(jid.clone())).await?; - let group_node = resp_node - .get_optional_child("group") - .ok_or_else(|| anyhow::anyhow!(" not found in group info response"))?; + let participants: Vec = group.participants.iter().map(|p| p.jid.clone()).collect(); - let mut participants = Vec::new(); - let mut lid_to_pn_map = HashMap::new(); - - let addressing_mode_str = group_node - .attrs() - .optional_string("addressing_mode") - .unwrap_or("pn"); - let addressing_mode = match addressing_mode_str { - "lid" => crate::types::message::AddressingMode::Lid, - _ => crate::types::message::AddressingMode::Pn, + let lid_to_pn_map: HashMap = if group.addressing_mode == AddressingMode::Lid { + group + .participants + .iter() + .filter_map(|p| p.phone_number.as_ref().map(|pn| (p.jid.user.clone(), pn.clone()))) + .collect() + } else { + HashMap::new() }; - for participant_node in group_node.get_children_by_tag("participant") { - let participant_jid = participant_node.attrs().jid("jid"); - participants.push(participant_jid.clone()); - - if addressing_mode == crate::types::message::AddressingMode::Lid - && let Some(phone_number) = participant_node.attrs().optional_jid("phone_number") - { - lid_to_pn_map.insert(participant_jid.user.clone(), phone_number); - } - } - - let mut info = GroupInfo::new(participants, addressing_mode); + let mut info = GroupInfo::new(participants, group.addressing_mode); if !lid_to_pn_map.is_empty() { info.set_lid_to_pn_map(lid_to_pn_map); } @@ -324,179 +86,63 @@ impl<'a> Groups<'a> { } pub async fn get_participating(&self) -> Result, anyhow::Error> { - let participants_node = NodeBuilder::new("participants").build(); - let description_node = NodeBuilder::new("description").build(); - let participating_node = NodeBuilder::new("participating") - .children([participants_node, description_node]) - .build(); - - let iq = InfoQuery::get( - "w:g2", - G_US_JID.clone(), - Some(NodeContent::Nodes(vec![participating_node])), - ); - - let resp_node = self.client.send_iq(iq).await?; - - let mut result = HashMap::new(); - - if let Some(groups_node) = resp_node.get_optional_child("groups") { - for group_node in groups_node.get_children_by_tag("group") { - let group_id_str = group_node.attrs().string("id"); - let group_jid: Jid = if group_id_str.contains('@') { - group_id_str - .parse() - .unwrap_or_else(|_| Jid::group(&group_id_str)) - } else { - Jid::group(&group_id_str) - }; - - let subject = group_node - .attrs() - .optional_string("subject") - .unwrap_or_default() - .to_string(); - - let addressing_mode_str = group_node - .attrs() - .optional_string("addressing_mode") - .unwrap_or("pn"); - let addressing_mode = match addressing_mode_str { - "lid" => crate::types::message::AddressingMode::Lid, - _ => crate::types::message::AddressingMode::Pn, - }; - - let mut participants = Vec::new(); - for participant_node in group_node.get_children_by_tag("participant") { - let jid = participant_node.attrs().jid("jid"); - let phone_number = participant_node.attrs().optional_jid("phone_number"); - let admin_type = participant_node.attrs().optional_string("type"); - let is_admin = admin_type == Some("admin") || admin_type == Some("superadmin"); - - participants.push(GroupParticipant { - jid, - phone_number, - is_admin, - }); - } + let response = self.client.execute(GroupParticipatingIq::new()).await?; + let result = response + .groups + .into_iter() + .map(|group| { + let key = group.id.to_string(); let metadata = GroupMetadata { - id: group_jid.clone(), - subject, - participants, - addressing_mode, + id: group.id, + subject: group.subject.into_string(), + participants: group.participants.into_iter().map(Into::into).collect(), + addressing_mode: group.addressing_mode, }; - - result.insert(group_jid.to_string(), metadata); - } - } + (key, metadata) + }) + .collect(); Ok(result) } pub async fn get_metadata(&self, jid: &Jid) -> Result { - let query_node = NodeBuilder::new("query") - .attr("request", "interactive") - .build(); - - let iq = InfoQuery::get( - "w:g2", - jid.clone(), - Some(NodeContent::Nodes(vec![query_node])), - ); - - let resp_node = self.client.send_iq(iq).await?; - - let group_node = resp_node - .get_optional_child("group") - .ok_or_else(|| anyhow::anyhow!(" not found in group info response"))?; - - let subject = group_node - .attrs() - .optional_string("subject") - .unwrap_or_default() - .to_string(); - - let addressing_mode_str = group_node - .attrs() - .optional_string("addressing_mode") - .unwrap_or("pn"); - let addressing_mode = match addressing_mode_str { - "lid" => crate::types::message::AddressingMode::Lid, - _ => crate::types::message::AddressingMode::Pn, - }; - - let mut participants = Vec::new(); - for participant_node in group_node.get_children_by_tag("participant") { - let participant_jid = participant_node.attrs().jid("jid"); - let phone_number = participant_node.attrs().optional_jid("phone_number"); - let admin_type = participant_node.attrs().optional_string("type"); - let is_admin = admin_type == Some("admin") || admin_type == Some("superadmin"); - - participants.push(GroupParticipant { - jid: participant_jid, - phone_number, - is_admin, - }); - } + let group = self.client.execute(GroupQueryIq::new(jid.clone())).await?; Ok(GroupMetadata { - id: jid.clone(), - subject, - participants, - addressing_mode, + id: group.id, + subject: group.subject.into_string(), + participants: group.participants.into_iter().map(Into::into).collect(), + addressing_mode: group.addressing_mode, }) } pub async fn create_group( &self, - options: GroupCreateOptions, + mut options: GroupCreateOptions, ) -> Result { - let mut resolved_options = options; - let mut resolved_participants = Vec::with_capacity(resolved_options.participants.len()); + // Resolve phone numbers for LID participants that don't have one + let mut resolved_participants = Vec::with_capacity(options.participants.len()); - for participant in resolved_options.participants.into_iter() { - if participant.jid.is_lid() && participant.phone_number.is_none() { - if let Some(phone_number) = self + for participant in options.participants { + let resolved = if participant.jid.is_lid() && participant.phone_number.is_none() { + let phone_number = self .client .get_phone_number_from_lid(&participant.jid.user) .await - { - resolved_participants - .push(participant.with_phone_number(Jid::pn(phone_number))); - } else { - return Err(anyhow::anyhow!( - "Missing phone number mapping for LID {}", - participant.jid - )); - } + .ok_or_else(|| { + anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid) + })?; + participant.with_phone_number(Jid::pn(phone_number)) } else { - resolved_participants.push(participant); - } + participant + }; + resolved_participants.push(resolved); } - resolved_options.participants = normalize_participants(&resolved_participants); - - let create_node = build_create_group_node(&resolved_options); - - let iq = InfoQuery::set( - "w:g2", - G_US_JID.clone(), - Some(NodeContent::Nodes(vec![create_node])), - ); - - let resp_node = self.client.send_iq(iq).await?; + options.participants = normalize_participants(&resolved_participants); - let group_node = resp_node - .get_optional_child("group") - .ok_or_else(|| anyhow::anyhow!(" not found in create group response"))?; - - let group_id_str = group_node.attrs().string("id"); - let gid: Jid = if group_id_str.contains('@') { - group_id_str.parse()? - } else { - Jid::group(&group_id_str) - }; + let gid = self.client.execute(GroupCreateIq::new(options)).await?; Ok(CreateGroupResult { gid }) } @@ -529,7 +175,7 @@ mod tests { phone_number: None, is_admin: true, }], - addressing_mode: crate::types::message::AddressingMode::Pn, + addressing_mode: AddressingMode::Pn, }; assert_eq!(metadata.subject, "Test Group"); @@ -537,97 +183,5 @@ mod tests { assert!(metadata.participants[0].is_admin); } - #[test] - fn test_normalize_participants_drops_phone_for_pn() { - let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap(); - let lid_jid: Jid = "100000000000001@lid".parse().unwrap(); - let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap(); - - let participants = vec![ - GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()), - GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()), - ]; - - let normalized = normalize_participants(&participants); - assert!(normalized[0].phone_number.is_none()); - assert_eq!(normalized[0].jid, pn_jid); - assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid)); - assert_eq!(normalized[1].jid, lid_jid); - } - - #[test] - fn test_build_create_group_node_includes_phone_number_for_lid() { - let lid_jid: Jid = "100000000000001@lid".parse().unwrap(); - let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap(); - - let options = GroupCreateOptions::new("subject").with_participants(vec![ - GroupParticipantOptions::from_lid_and_phone(lid_jid.clone(), phone_jid.clone()), - ]); - - let create_node = build_create_group_node(&options); - let participants = create_node.get_children_by_tag("participant"); - assert_eq!(participants.len(), 1); - - let participant = participants[0]; - let participant_jid = participant.attrs().jid("jid"); - let participant_phone = participant.attrs().optional_jid("phone_number"); - - assert_eq!(participant_jid, lid_jid); - assert_eq!(participant_phone, Some(phone_jid)); - } - - #[test] - fn test_build_create_group_node_includes_modes_ephemeral_and_membership() { - let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap(); - let options = GroupCreateOptions::new("subject") - .with_participant(GroupParticipantOptions::from_phone(pn_jid)) - .with_member_link_mode(MemberLinkMode::AllMemberLink) - .with_member_add_mode(MemberAddMode::AdminAdd) - .with_membership_approval_mode(MembershipApprovalMode::On) - .with_ephemeral_expiration(86400); - - let create_node = build_create_group_node(&options); - - let link_mode = create_node.get_children_by_tag("member_link_mode")[0]; - let add_mode = create_node.get_children_by_tag("member_add_mode")[0]; - let ephemeral = create_node.get_children_by_tag("ephemeral")[0]; - let approval = create_node.get_children_by_tag("membership_approval_mode")[0]; - let join = approval.get_children_by_tag("group_join")[0]; - - let link_mode_value = match link_mode.content.as_ref() { - Some(NodeContent::String(value)) => value.as_str(), - _ => "", - }; - let add_mode_value = match add_mode.content.as_ref() { - Some(NodeContent::String(value)) => value.as_str(), - _ => "", - }; - - assert_eq!(link_mode_value, "all_member_link"); - assert_eq!(add_mode_value, "admin_add"); - assert_eq!(ephemeral.attrs().string("expiration"), "86400"); - assert_eq!(join.attrs().string("state"), "on"); - } - - #[test] - fn test_build_create_group_node_includes_privacy_child() { - let lid_jid: Jid = "100000000000001@lid".parse().unwrap(); - let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap(); - - let options = GroupCreateOptions::new("subject").with_participants(vec![ - GroupParticipantOptions::from_lid_and_phone(lid_jid, phone_jid) - .with_privacy(vec![0x01, 0x02, 0x0f]), - ]); - - let create_node = build_create_group_node(&options); - let participant = create_node.get_children_by_tag("participant")[0]; - let privacy = participant.get_children_by_tag("privacy")[0]; - - let privacy_value = match privacy.content.as_ref() { - Some(NodeContent::String(value)) => value.as_str(), - _ => "", - }; - - assert_eq!(privacy_value, "01020f"); - } + // Protocol-level tests (node building, parsing, validation) are in wacore/src/iq/groups.rs } diff --git a/src/features/mex.rs b/src/features/mex.rs index 71fb9d6d1..7042b8664 100644 --- a/src/features/mex.rs +++ b/src/features/mex.rs @@ -1,12 +1,17 @@ +//! MEX (Meta Exchange) GraphQL feature. +//! +//! Protocol types are defined in `wacore::iq::mex`. + use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::InfoQuery; -use serde::{Deserialize, Serialize}; +use crate::request::IqError; use serde_json::Value; use thiserror::Error; -use wacore_binary::builder::NodeBuilder; -use wacore_binary::node::{Node, NodeContent}; +use wacore::iq::mex::MexQuerySpec; + +// Re-export types from wacore +pub use wacore::iq::mex::{MexErrorExtensions, MexGraphQLError, MexResponse}; +/// Error types for MEX operations. #[derive(Debug, Error)] pub enum MexError { #[error("MEX payload parsing error: {0}")] @@ -16,84 +21,22 @@ pub enum MexError { ExtensionError { code: i32, message: String }, #[error("IQ request failed: {0}")] - Request(#[from] Box), + Request(#[from] IqError), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), } +/// MEX request with document ID and variables. #[derive(Debug, Clone)] pub struct MexRequest<'a> { + /// GraphQL document ID. pub doc_id: &'a str, - + /// Query variables. pub variables: Value, } -#[derive(Serialize)] -struct MexPayload<'a> { - variables: &'a Value, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct MexResponse { - pub data: Option, - - pub errors: Option>, -} - -impl MexResponse { - #[inline] - pub fn has_data(&self) -> bool { - self.data.is_some() - } - - #[inline] - pub fn has_errors(&self) -> bool { - self.errors.as_ref().is_some_and(|e| !e.is_empty()) - } - - pub fn fatal_error(&self) -> Option<&MexGraphQLError> { - self.errors.as_ref()?.iter().find(|e| { - e.extensions - .as_ref() - .is_some_and(|ext| ext.is_summary == Some(true)) - }) - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct MexGraphQLError { - pub message: String, - - pub extensions: Option, -} - -impl MexGraphQLError { - #[inline] - pub fn error_code(&self) -> Option { - self.extensions.as_ref()?.error_code - } - - #[inline] - pub fn is_fatal(&self) -> bool { - self.extensions - .as_ref() - .is_some_and(|ext| ext.is_summary == Some(true)) - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct MexErrorExtensions { - pub error_code: Option, - - pub is_summary: Option, - - #[serde(default)] - pub is_retryable: Option, - - pub severity: Option, -} - +/// Feature handle for MEX GraphQL operations. pub struct Mex<'a> { client: &'a Client, } @@ -103,50 +46,24 @@ impl<'a> Mex<'a> { Self { client } } + /// Execute a GraphQL query. #[inline] pub async fn query(&self, request: MexRequest<'_>) -> Result { - self.execute(request).await + self.execute_request(request).await } + /// Execute a GraphQL mutation. #[inline] pub async fn mutate(&self, request: MexRequest<'_>) -> Result { - self.execute(request).await + self.execute_request(request).await } - async fn execute(&self, request: MexRequest<'_>) -> Result { - let payload = MexPayload { - variables: &request.variables, - }; - let payload_bytes = serde_json::to_vec(&payload)?; - - let query_node = NodeBuilder::new("query") - .attr("query_id", request.doc_id) - .bytes(payload_bytes) - .build(); - - let iq = InfoQuery::get( - "w:mex", - server_jid(), - Some(NodeContent::Nodes(vec![query_node])), - ); + async fn execute_request(&self, request: MexRequest<'_>) -> Result { + let spec = MexQuerySpec::new(request.doc_id, request.variables); - let response_node = self.client.send_iq(iq).await.map_err(Box::new)?; - - Self::parse_response(&response_node) - } - - fn parse_response(node: &Node) -> Result { - let result_node = node - .get_optional_child("result") - .ok_or_else(|| MexError::PayloadParsing("Missing node".into()))?; - - let result_bytes = match &result_node.content { - Some(NodeContent::Bytes(bytes)) => bytes, - _ => return Err(MexError::PayloadParsing("Result not binary".into())), - }; - - let response: MexResponse = serde_json::from_slice(result_bytes)?; + let response = self.client.execute(spec).await?; + // Check for fatal errors (the IqSpec already checks, but we want to return our error type) if let Some(fatal) = response.fatal_error() { let code = fatal.error_code().unwrap_or(500); return Err(MexError::ExtensionError { @@ -171,27 +88,6 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn test_mex_payload_serialization() { - let variables = json!({ - "input": { - "query_input": [{"jid": "1234567890@s.whatsapp.net"}] - }, - "include_username": true - }); - - let payload = MexPayload { - variables: &variables, - }; - - let serialized = serde_json::to_string(&payload).unwrap(); - - assert!(serialized.starts_with("{\"variables\":")); - assert!(!serialized.contains("\"id\":")); - assert!(serialized.contains("\"include_username\":true")); - assert!(serialized.contains("\"query_input\"")); - } - #[test] fn test_mex_request_borrows_doc_id() { let doc_id = "29829202653362039"; diff --git a/src/features/presence.rs b/src/features/presence.rs index 3f8038092..54a7966a2 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -1,28 +1,19 @@ +//! Presence (online status) feature. + use crate::client::Client; use log::{debug, info, warn}; +use wacore::StringEnum; use wacore_binary::builder::NodeBuilder; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Presence status for online/offline state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum PresenceStatus { + #[str = "available"] Available, + #[str = "unavailable"] Unavailable, } -impl PresenceStatus { - fn as_str(&self) -> &'static str { - match self { - PresenceStatus::Available => "available", - PresenceStatus::Unavailable => "unavailable", - } - } -} - -impl std::fmt::Display for PresenceStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - impl From for PresenceStatus { fn from(p: crate::types::presence::Presence) -> Self { match p { @@ -32,6 +23,7 @@ impl From for PresenceStatus { } } +/// Feature handle for presence operations. pub struct Presence<'a> { client: &'a Client, } @@ -41,6 +33,7 @@ impl<'a> Presence<'a> { Self { client } } + /// Set the presence status. pub async fn set(&self, status: PresenceStatus) -> Result<(), anyhow::Error> { let device_snapshot = self .client @@ -76,16 +69,19 @@ impl<'a> Presence<'a> { self.client.send_node(node).await.map_err(|e| e.into()) } + /// Set presence to available (online). pub async fn set_available(&self) -> Result<(), anyhow::Error> { self.set(PresenceStatus::Available).await } + /// Set presence to unavailable (offline). pub async fn set_unavailable(&self) -> Result<(), anyhow::Error> { self.set(PresenceStatus::Unavailable).await } } impl Client { + /// Access presence operations. #[allow(clippy::wrong_self_convention)] pub fn presence(&self) -> Presence<'_> { Presence::new(self) @@ -97,14 +93,8 @@ mod tests { use super::*; #[test] - fn test_presence_status_display() { - assert_eq!(PresenceStatus::Available.to_string(), "available"); - assert_eq!(PresenceStatus::Unavailable.to_string(), "unavailable"); - } - - #[test] - fn test_presence_status_as_str() { + fn test_presence_status_string_enum() { assert_eq!(PresenceStatus::Available.as_str(), "available"); - assert_eq!(PresenceStatus::Unavailable.as_str(), "unavailable"); + assert_eq!(PresenceStatus::Unavailable.to_string(), "unavailable"); } } diff --git a/src/mediaconn.rs b/src/mediaconn.rs index 17d14c2d3..11a8ed3d1 100644 --- a/src/mediaconn.rs +++ b/src/mediaconn.rs @@ -1,24 +1,30 @@ +//! Media connection management. +//! +//! Protocol types are defined in `wacore::iq::mediaconn`. + use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::{InfoQuery, IqError}; -use serde::Deserialize; +use crate::request::IqError; use std::time::{Duration, Instant}; -use wacore_binary::builder::NodeBuilder; +use wacore::iq::mediaconn::MediaConnSpec; -#[derive(Debug, Clone, Deserialize)] -pub struct MediaConnHost { - pub hostname: String, -} +/// Re-export the host type from wacore. +pub use wacore::iq::mediaconn::MediaConnHost; +/// Media connection with runtime-specific fields. #[derive(Debug, Clone)] pub struct MediaConn { + /// Authentication token for media operations. pub auth: String, + /// Time-to-live in seconds. pub ttl: u64, + /// Available media hosts. pub hosts: Vec, + /// When this connection info was fetched (runtime-specific). pub fetched_at: Instant, } impl MediaConn { + /// Check if this connection info has expired. pub fn is_expired(&self) -> bool { self.fetched_at.elapsed() > Duration::from_secs(self.ttl) } @@ -36,38 +42,12 @@ impl Client { } } - let resp = self - .send_iq(InfoQuery::set( - "w:m", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![ - NodeBuilder::new("media_conn").build(), - ])), - )) - .await?; - - let media_conn_node = - resp.get_optional_child("media_conn") - .ok_or_else(|| IqError::ServerError { - code: 500, - text: "Missing media_conn node in response".to_string(), - })?; - - let mut attrs = media_conn_node.attrs(); - let auth = attrs.string("auth"); - let ttl = attrs.optional_u64("ttl").unwrap_or(0); - - let mut hosts = Vec::new(); - for host_node in media_conn_node.get_children_by_tag("host") { - hosts.push(MediaConnHost { - hostname: host_node.attrs().string("hostname"), - }); - } + let response = self.execute(MediaConnSpec::new()).await?; let new_conn = MediaConn { - auth, - ttl, - hosts, + auth: response.auth, + ttl: response.ttl, + hosts: response.hosts, fetched_at: Instant::now(), }; diff --git a/src/prekeys.rs b/src/prekeys.rs index 89ed3ee2b..7dadee97a 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -1,18 +1,18 @@ +//! Pre-key management for Signal Protocol. +//! +//! Protocol types are defined in `wacore::iq::prekeys`. + use crate::client::Client; use crate::jid_utils::server_jid; -use log; -use wacore::libsignal::protocol::PreKeyBundle; -use wacore_binary::jid::Jid; -use wacore_binary::node::NodeContent; - use crate::request::InfoQuery; -use wacore_binary::builder::NodeBuilder; - use anyhow; +use log; use rand::TryRngCore; use rand_core::OsRng; -use wacore::libsignal::protocol::KeyPair; +use wacore::iq::prekeys::{PreKeyCountSpec, PreKeyFetchSpec}; +use wacore::libsignal::protocol::{KeyPair, PreKeyBundle}; use wacore::libsignal::store::record_helpers::new_pre_key_record; +use wacore_binary::jid::Jid; pub use wacore::prekeys::PreKeyUtils; @@ -25,17 +25,12 @@ impl Client { jids: &[Jid], reason: Option<&str>, ) -> Result, anyhow::Error> { - let content = PreKeyUtils::build_fetch_prekeys_request(jids, reason); - - let resp_node = self - .send_iq(crate::request::InfoQuery::get( - "encrypt", - server_jid(), - Some(NodeContent::Nodes(vec![content])), - )) - .await?; + let spec = match reason { + Some(r) => PreKeyFetchSpec::with_reason(jids.to_vec(), r), + None => PreKeyFetchSpec::new(jids.to_vec()), + }; - let bundles = PreKeyUtils::parse_prekeys_response(&resp_node)?; + let bundles = self.execute(spec).await?; for jid in bundles.keys() { log::debug!("Successfully parsed pre-key bundle for {jid}"); @@ -46,27 +41,8 @@ impl Client { /// Query the WhatsApp server for how many pre-keys it currently has for this device. pub(crate) async fn get_server_pre_key_count(&self) -> Result { - let count_node = NodeBuilder::new("count").build(); - let iq = InfoQuery::get( - "encrypt", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![count_node])), - ); - - let resp_node = self.send_iq(iq).await?; - let count_resp_node = resp_node.get_optional_child("count").ok_or_else(|| { - crate::request::IqError::ServerError { - code: 500, - text: "Missing count node in response".to_string(), - } - })?; - - let count_str = count_resp_node - .attrs() - .optional_string("value") - .unwrap_or("0"); - let count = count_str.parse::().unwrap_or(0); - Ok(count) + let response = self.execute(PreKeyCountSpec::new()).await?; + Ok(response.count) } /// Ensure the server has at least MIN_PRE_KEY_COUNT pre-keys, and upload a batch of diff --git a/src/request.rs b/src/request.rs index 288173292..fa83c551d 100644 --- a/src/request.rs +++ b/src/request.rs @@ -23,6 +23,8 @@ pub enum IqError { ServerError { code: u16, text: String }, #[error("Internal channel closed unexpectedly")] InternalChannelClosed, + #[error("Failed to parse IQ response: {0}")] + ParseError(#[from] anyhow::Error), } impl From for IqError { @@ -160,6 +162,28 @@ impl Client { } } + /// Executes an IQ specification and returns the typed response. + /// + /// This is a convenience method that combines building the IQ request, + /// sending it, and parsing the response into a single operation. + /// + /// # Example + /// + /// ```ignore + /// use wacore::iq::groups::GroupQueryIq; + /// + /// let group_info = client.execute(GroupQueryIq::new(group_jid)).await?; + /// println!("Group subject: {}", group_info.subject); + /// ``` + pub async fn execute(&self, spec: S) -> Result + where + S: wacore::iq::spec::IqSpec, + { + let iq = spec.build_iq(); + let response = self.send_iq(iq).await?; + spec.parse_response(&response).map_err(IqError::ParseError) + } + /// Handles an IQ response by checking if there's a waiter for this response ID. /// /// This method accepts an `Arc` - if there's a waiter, we clone the Arc (cheap) diff --git a/src/spam_report.rs b/src/spam_report.rs index e498759c5..71d2f9076 100644 --- a/src/spam_report.rs +++ b/src/spam_report.rs @@ -1,8 +1,12 @@ +//! Spam reporting feature. +//! +//! Types and IQ specification are defined in `wacore::iq::spam_report`. + use crate::client::Client; -use crate::request::{InfoQuery, IqError}; -use wacore_binary::jid::{Jid, SERVER_JID}; -use wacore_binary::node::NodeContent; +use crate::request::IqError; +use wacore::iq::spam_report::SpamReportSpec; +// Re-export types from wacore pub use wacore::types::{SpamFlow, SpamReportRequest, SpamReportResult, build_spam_list_node}; impl Client { @@ -31,27 +35,8 @@ impl Client { &self, request: SpamReportRequest, ) -> Result { - let spam_list_node = build_spam_list_node(&request); - - let server_jid = Jid::new("", SERVER_JID); - - let query = InfoQuery::set( - "spam", - server_jid, - Some(NodeContent::Nodes(vec![spam_list_node])), - ); - - let response = self.send_iq(query).await?; - - // Extract report_id from response if present - let report_id = response - .get_optional_child_by_tag(&["report_id"]) - .and_then(|n| match &n.content { - Some(NodeContent::String(s)) => Some(s.clone()), - _ => None, - }); - - Ok(SpamReportResult { report_id }) + let spec = SpamReportSpec::new(request); + self.execute(spec).await } } diff --git a/src/usync.rs b/src/usync.rs index 2f655c46e..499526f59 100644 --- a/src/usync.rs +++ b/src/usync.rs @@ -1,9 +1,12 @@ +//! User device list synchronization. +//! +//! Device list IQ specification is defined in `wacore::iq::usync`. + use crate::client::Client; -use crate::jid_utils::server_jid; use log::{debug, warn}; use std::collections::HashSet; +use wacore::iq::usync::DeviceListSpec; use wacore_binary::jid::Jid; -use wacore_binary::node::NodeContent; impl Client { pub(crate) async fn get_user_devices(&self, jids: &[Jid]) -> Result, anyhow::Error> { @@ -31,20 +34,12 @@ impl Client { let sid = self.generate_request_id(); let jids_vec: Vec = jids_to_fetch.into_iter().collect(); - let usync_node = wacore::usync::build_get_user_devices_query(&jids_vec, sid.as_str()); + let spec = DeviceListSpec::new(jids_vec, sid); - let iq = crate::request::InfoQuery::get( - "usync", - server_jid(), - Some(NodeContent::Nodes(vec![usync_node])), - ); - let resp_node = self.send_iq(iq).await?; - let user_device_lists = - wacore::usync::parse_get_user_devices_response_with_phash(&resp_node)?; + let response = self.execute(spec).await?; // Extract and persist LID mappings from the response - let lid_mappings = wacore::usync::parse_lid_mappings_from_response(&resp_node); - for mapping in lid_mappings { + for mapping in &response.lid_mappings { if let Err(err) = self .add_lid_pn_mapping( &mapping.lid, @@ -66,7 +61,7 @@ impl Client { } // 3. Update the cache with the newly fetched data (now with phash) - for user_list in &user_device_lists { + for user_list in &response.device_lists { self.get_device_cache() .await .insert(user_list.user.clone(), user_list.devices.clone()) @@ -118,7 +113,8 @@ impl Client { } // Collect all devices for return - let fetched_devices: Vec = user_device_lists + let fetched_devices: Vec = response + .device_lists .into_iter() .flat_map(|u| u.devices) .collect(); diff --git a/wacore/Cargo.toml b/wacore/Cargo.toml index 95b5e0d62..93ce86134 100644 --- a/wacore/Cargo.toml +++ b/wacore/Cargo.toml @@ -9,6 +9,8 @@ description = "Core WhatsApp protocol implementation without runtime dependencie [dependencies] aes = { workspace = true } +typed-builder = "0.20" +wacore-derive = { workspace = true } aes-gcm = { workspace = true } anyhow = { workspace = true } async-channel = { workspace = true } @@ -31,6 +33,7 @@ rand = { workspace = true } rand_core = { workspace = true } serde = { workspace = true } serde-big-array = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } wacore-appstate = { workspace = true } diff --git a/wacore/derive/Cargo.toml b/wacore/derive/Cargo.toml new file mode 100644 index 000000000..435101685 --- /dev/null +++ b/wacore/derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wacore-derive" +version = "0.2.0" +edition = "2024" +authors = ["João Lucas "] +license = "MIT" +repository = "https://github.com/jlucaso1/whatsapp-rust" +description = "Derive macros for wacore protocol types" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits"] } diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs new file mode 100644 index 000000000..226535061 --- /dev/null +++ b/wacore/derive/src/lib.rs @@ -0,0 +1,409 @@ +//! Derive macros for wacore protocol types. +//! +//! This crate provides derive macros for implementing the `ProtocolNode` trait +//! on structs that represent WhatsApp protocol nodes. +//! +//! # Example +//! +//! ```ignore +//! use wacore_derive::{ProtocolNode, StringEnum}; +//! +//! /// A query request node. +//! /// Wire format: `` +//! #[derive(ProtocolNode)] +//! #[protocol(tag = "query")] +//! pub struct QueryRequest { +//! #[attr(name = "request", default = "interactive")] +//! pub request_type: String, +//! } +//! +//! /// An enum with string representation. +//! #[derive(StringEnum)] +//! pub enum MemberAddMode { +//! #[str = "admin_add"] +//! AdminAdd, +//! #[str = "all_member_add"] +//! AllMemberAdd, +//! } +//! ``` + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields, parse_macro_input}; + +/// Derive macro for implementing `ProtocolNode` on structs with attributes. +/// +/// # Attributes +/// +/// - `#[protocol(tag = "tagname")]` - Required. Specifies the XML tag name. +/// - `#[attr(name = "attrname")]` - Marks a field as an XML attribute. +/// - `#[attr(name = "attrname", default = "value")]` - Attribute with default value. +/// +/// # Example +/// +/// ```ignore +/// #[derive(ProtocolNode)] +/// #[protocol(tag = "query")] +/// pub struct QueryRequest { +/// #[attr(name = "request", default = "interactive")] +/// pub request_type: String, +/// } +/// ``` +#[proc_macro_derive(ProtocolNode, attributes(protocol, attr))] +pub fn derive_protocol_node(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + + // Extract tag from #[protocol(tag = "...")] + let tag = extract_tag(&input.attrs).expect("ProtocolNode requires #[protocol(tag = \"...\")]"); + + // Get fields for struct + let fields = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => &fields.named, + Fields::Unit => { + // Unit struct - no fields + return generate_empty_impl(name, &tag).into(); + } + _ => panic!("ProtocolNode only supports named fields or unit structs"), + }, + _ => panic!("ProtocolNode can only be derived for structs"), + }; + + // Collect field info + let mut attr_fields = Vec::new(); + for field in fields { + if let Some(attr_info) = extract_attr_info(field) { + attr_fields.push(attr_info); + } + } + + // Generate into_node() body + let attr_setters: Vec<_> = attr_fields + .iter() + .map(|info| { + let field_ident = &info.field_ident; + let attr_name = &info.attr_name; + quote! { + .attr(#attr_name, self.#field_ident.to_string()) + } + }) + .collect(); + + // Generate try_from_node() body + let field_parsers: Vec<_> = attr_fields + .iter() + .map(|info| { + let field_ident = &info.field_ident; + let attr_name = &info.attr_name; + if let Some(default) = &info.default { + quote! { + #field_ident: node.attrs().optional_string(#attr_name) + .map(|s| s.to_string()) + .unwrap_or_else(|| #default.to_string()) + } + } else { + quote! { + #field_ident: node.attrs().string(#attr_name).to_string() + } + } + }) + .collect(); + + // Generate Default impl field initializers + let default_fields: Vec<_> = attr_fields + .iter() + .map(|info| { + let field_ident = &info.field_ident; + if let Some(default) = &info.default { + quote! { #field_ident: #default.to_string() } + } else { + quote! { #field_ident: String::new() } + } + }) + .collect(); + + let expanded = quote! { + impl crate::protocol::ProtocolNode for #name { + fn tag(&self) -> &'static str { + #tag + } + + fn into_node(self) -> wacore_binary::node::Node { + wacore_binary::builder::NodeBuilder::new(#tag) + #(#attr_setters)* + .build() + } + + fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + if node.tag != #tag { + return Err(anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); + } + Ok(Self { + #(#field_parsers),* + }) + } + } + + impl Default for #name { + fn default() -> Self { + Self { + #(#default_fields),* + } + } + } + }; + + expanded.into() +} + +/// Derive macro for empty protocol nodes (tag only, no attributes). +/// +/// # Attributes +/// +/// - `#[protocol(tag = "tagname")]` - Required. Specifies the XML tag name. +/// +/// # Example +/// +/// ```ignore +/// #[derive(EmptyNode)] +/// #[protocol(tag = "participants")] +/// pub struct ParticipantsRequest; +/// ``` +#[proc_macro_derive(EmptyNode, attributes(protocol))] +pub fn derive_empty_node(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + + // Extract tag from #[protocol(tag = "...")] + let tag = extract_tag(&input.attrs).expect("EmptyNode requires #[protocol(tag = \"...\")]"); + + generate_empty_impl(name, &tag).into() +} + +fn generate_empty_impl(name: &syn::Ident, tag: &str) -> proc_macro2::TokenStream { + quote! { + impl crate::protocol::ProtocolNode for #name { + fn tag(&self) -> &'static str { + #tag + } + + fn into_node(self) -> wacore_binary::node::Node { + wacore_binary::builder::NodeBuilder::new(#tag).build() + } + + fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + if node.tag != #tag { + return Err(anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); + } + Ok(Self) + } + } + + impl Default for #name { + fn default() -> Self { + Self + } + } + } +} + +struct AttrFieldInfo { + field_ident: syn::Ident, + attr_name: String, + default: Option, +} + +fn extract_tag(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("protocol") { + let mut tag = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("tag") { + let value: syn::LitStr = meta.value()?.parse()?; + tag = Some(value.value()); + } + Ok(()) + }); + if tag.is_some() { + return tag; + } + } + } + None +} + +fn extract_attr_info(field: &syn::Field) -> Option { + let field_ident = field.ident.clone()?; + + for attr in &field.attrs { + if attr.path().is_ident("attr") { + // Parse the attribute arguments + let mut attr_name = None; + let mut default = None; + + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + let value: syn::LitStr = meta.value()?.parse()?; + attr_name = Some(value.value()); + } else if meta.path.is_ident("default") { + let value: syn::LitStr = meta.value()?.parse()?; + default = Some(value.value()); + } + Ok(()) + }); + + if let Some(name) = attr_name { + return Some(AttrFieldInfo { + field_ident, + attr_name: name, + default, + }); + } + } + } + None +} + +/// Derive macro for enums with string representations. +/// +/// Automatically implements: +/// - `as_str(&self) -> &'static str` +/// - `std::fmt::Display` +/// - `TryFrom<&str>` +/// - `Default` (first variant is default, or use `#[string_default]`) +/// +/// # Attributes +/// +/// - `#[str = "value"]` - Required on each variant. The string representation. +/// - `#[string_default]` - Optional. Marks this variant as the default. +/// +/// # Example +/// +/// ```ignore +/// #[derive(StringEnum)] +/// pub enum MemberAddMode { +/// #[str = "admin_add"] +/// AdminAdd, +/// #[string_default] +/// #[str = "all_member_add"] +/// AllMemberAdd, +/// } +/// +/// assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add"); +/// assert_eq!(MemberAddMode::try_from("all_member_add").unwrap(), MemberAddMode::AllMemberAdd); +/// ``` +#[proc_macro_derive(StringEnum, attributes(str, string_default))] +pub fn derive_string_enum(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + + let variants = match &input.data { + Data::Enum(data) => &data.variants, + _ => panic!("StringEnum can only be derived for enums"), + }; + + let mut variant_infos = Vec::new(); + let mut default_variant = None; + + for variant in variants { + let variant_ident = &variant.ident; + let mut str_value = None; + let mut is_default = false; + + for attr in &variant.attrs { + if attr.path().is_ident("str") { + // Parse #[str = "value"] + if let syn::Meta::NameValue(nv) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &nv.value + && let syn::Lit::Str(lit_str) = &expr_lit.lit + { + str_value = Some(lit_str.value()); + } + } else if attr.path().is_ident("string_default") { + is_default = true; + } + } + + let str_val = str_value.unwrap_or_else(|| { + panic!( + "StringEnum variant {} requires #[str = \"...\"] attribute", + variant_ident + ) + }); + + if is_default { + default_variant = Some(variant_ident.clone()); + } + + variant_infos.push((variant_ident.clone(), str_val)); + } + + // Check for empty enums + if variant_infos.is_empty() { + return syn::Error::new_spanned( + &input.ident, + "StringEnum cannot be derived for empty enums", + ) + .to_compile_error() + .into(); + } + + // If no explicit default, use first variant + let default_variant = default_variant.unwrap_or_else(|| variant_infos[0].0.clone()); + + // Generate as_str() match arms + let as_str_arms: Vec<_> = variant_infos + .iter() + .map(|(ident, str_val)| { + quote! { #name::#ident => #str_val } + }) + .collect(); + + // Generate TryFrom match arms + let try_from_arms: Vec<_> = variant_infos + .iter() + .map(|(ident, str_val)| { + quote! { #str_val => Ok(#name::#ident) } + }) + .collect(); + + let expanded = quote! { + impl #name { + /// Returns the string representation of this enum variant. + pub fn as_str(&self) -> &'static str { + match self { + #(#as_str_arms),* + } + } + } + + impl std::fmt::Display for #name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl TryFrom<&str> for #name { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + #(#try_from_arms),*, + _ => Err(anyhow::anyhow!("unknown {}: {}", stringify!(#name), value)), + } + } + } + + impl Default for #name { + fn default() -> Self { + #name::#default_variant + } + } + }; + + expanded.into() +} diff --git a/wacore/src/iq/blocklist.rs b/wacore/src/iq/blocklist.rs new file mode 100644 index 000000000..741f9e904 --- /dev/null +++ b/wacore/src/iq/blocklist.rs @@ -0,0 +1,351 @@ +//! Blocklist IQ types and specifications. +//! +//! This module provides type-safe structures for blocklist operations following +//! the `ProtocolNode` pattern defined in `wacore/src/protocol.rs`. + +use crate::StringEnum; +use crate::iq::node::{optional_attr, optional_child, optional_u64}; +use crate::iq::spec::IqSpec; +use crate::protocol::ProtocolNode; +use crate::request::InfoQuery; +use anyhow::{Result, anyhow}; +use log::warn; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +// ============================================================================ +// Constants +// ============================================================================ + +/// IQ namespace for blocklist operations. +pub const BLOCKLIST_IQ_NAMESPACE: &str = "blocklist"; + +// ============================================================================ +// Enums +// ============================================================================ + +/// Action to perform on a blocklist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum BlocklistAction { + #[str = "block"] + Block, + #[str = "unblock"] + Unblock, +} + +// ============================================================================ +// Request Types +// ============================================================================ + +/// Request node for updating blocklist. +/// +/// Wire format: `` +#[derive(Debug, Clone)] +pub struct BlocklistItemRequest { + pub jid: Jid, + pub action: BlocklistAction, +} + +impl BlocklistItemRequest { + pub fn new(jid: &Jid, action: BlocklistAction) -> Self { + Self { + jid: jid.clone(), + action, + } + } + + pub fn block(jid: &Jid) -> Self { + Self::new(jid, BlocklistAction::Block) + } + + pub fn unblock(jid: &Jid) -> Self { + Self::new(jid, BlocklistAction::Unblock) + } +} + +impl ProtocolNode for BlocklistItemRequest { + fn tag(&self) -> &'static str { + "item" + } + + fn into_node(self) -> Node { + NodeBuilder::new("item") + .attr("action", self.action.as_str()) + .attr("jid", self.jid.to_string()) + .build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "item" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + + let action_str = optional_attr(node, "action").unwrap_or("block"); + let action = BlocklistAction::try_from(action_str)?; + let jid_str = optional_attr(node, "jid") + .ok_or_else(|| anyhow!("missing jid attribute"))?; + let jid = jid_str.parse()?; + + Ok(Self { jid, action }) + } +} + +// ============================================================================ +// Response Types +// ============================================================================ + +/// A single blocklist entry from the response. +/// +/// Wire format: `` +#[derive(Debug, Clone)] +pub struct BlocklistEntry { + pub jid: Jid, + pub timestamp: Option, +} + +impl ProtocolNode for BlocklistEntry { + fn tag(&self) -> &'static str { + "item" + } + + fn into_node(self) -> Node { + let mut builder = NodeBuilder::new("item").attr("jid", self.jid.to_string()); + if let Some(t) = self.timestamp { + builder = builder.attr("t", t.to_string()); + } + builder.build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "item" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + + let jid_str = optional_attr(node, "jid") + .ok_or_else(|| anyhow!("missing jid attribute"))?; + let jid = jid_str.parse()?; + let timestamp = optional_u64(node, "t"); + + Ok(Self { jid, timestamp }) + } +} + +/// Response containing the blocklist entries. +/// +/// Wire format: `` or `` +#[derive(Debug, Clone, Default)] +pub struct BlocklistResponse { + pub entries: Vec, +} + +impl ProtocolNode for BlocklistResponse { + fn tag(&self) -> &'static str { + "list" + } + + fn into_node(self) -> Node { + let children: Vec = self.entries.into_iter().map(|e| e.into_node()).collect(); + NodeBuilder::new("list").children(children).build() + } + + fn try_from_node(node: &Node) -> Result { + // Response can be either: + // 1. + // 2. Direct children in the response node + let items = if let Some(list) = optional_child(node, "list") { + list.get_children_by_tag("item") + } else { + node.get_children_by_tag("item") + }; + + let entries = items + .iter() + .filter_map(|item| match BlocklistEntry::try_from_node(item) { + Ok(entry) => Some(entry), + Err(e) => { + warn!( + target: "blocklist", + "Failed to parse blocklist entry: {e}" + ); + None + } + }) + .collect(); + + Ok(Self { entries }) + } +} + +// ============================================================================ +// IqSpec Implementations +// ============================================================================ + +/// IQ spec for fetching the blocklist. +/// +/// Wire format: +/// - Request: `` +/// - Response: `...` +#[derive(Debug, Default, Clone, Copy)] +pub struct GetBlocklistSpec; + +impl IqSpec for GetBlocklistSpec { + type Response = Vec; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get(BLOCKLIST_IQ_NAMESPACE, Jid::new("", SERVER_JID), None) + } + + fn parse_response(&self, response: &Node) -> Result { + let blocklist = BlocklistResponse::try_from_node(response)?; + Ok(blocklist.entries) + } +} + +/// IQ spec for updating the blocklist (block/unblock). +/// +/// Wire format: +/// - Request: `` +/// - Response: Empty (just success acknowledgement) +#[derive(Debug, Clone)] +pub struct UpdateBlocklistSpec { + request: BlocklistItemRequest, +} + +impl UpdateBlocklistSpec { + pub fn new(jid: &Jid, action: BlocklistAction) -> Self { + Self { + request: BlocklistItemRequest::new(jid, action), + } + } + + pub fn block(jid: &Jid) -> Self { + Self { + request: BlocklistItemRequest::block(jid), + } + } + + pub fn unblock(jid: &Jid) -> Self { + Self { + request: BlocklistItemRequest::unblock(jid), + } + } +} + +impl IqSpec for UpdateBlocklistSpec { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::set( + BLOCKLIST_IQ_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![self.request.clone().into_node()])), + ) + } + + fn parse_response(&self, _response: &Node) -> Result { + Ok(()) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blocklist_action_string_enum() { + assert_eq!(BlocklistAction::Block.as_str(), "block"); + assert_eq!(BlocklistAction::Unblock.as_str(), "unblock"); + assert_eq!(BlocklistAction::try_from("block").unwrap(), BlocklistAction::Block); + assert_eq!(BlocklistAction::try_from("unblock").unwrap(), BlocklistAction::Unblock); + } + + #[test] + fn test_blocklist_item_request_into_node() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let request = BlocklistItemRequest::block(&jid); + let node = request.into_node(); + + assert_eq!(node.tag, "item"); + assert_eq!(node.attrs().string("action"), "block"); + assert_eq!(node.attrs().string("jid"), "1234567890@s.whatsapp.net"); + } + + #[test] + fn test_blocklist_entry_into_node() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let entry = BlocklistEntry { + jid: jid.clone(), + timestamp: Some(1234567890), + }; + let node = entry.into_node(); + + assert_eq!(node.tag, "item"); + assert_eq!(node.attrs().string("jid"), "1234567890@s.whatsapp.net"); + assert_eq!(node.attrs().string("t"), "1234567890"); + } + + #[test] + fn test_blocklist_entry_try_from_node() { + let node = NodeBuilder::new("item") + .attr("jid", "1234567890@s.whatsapp.net") + .attr("t", "1234567890") + .build(); + + let entry = BlocklistEntry::try_from_node(&node).unwrap(); + assert_eq!(entry.jid.user, "1234567890"); + assert_eq!(entry.timestamp, Some(1234567890)); + } + + #[test] + fn test_blocklist_response_with_list_wrapper() { + let list_node = NodeBuilder::new("list") + .children([ + NodeBuilder::new("item") + .attr("jid", "111@s.whatsapp.net") + .build(), + NodeBuilder::new("item") + .attr("jid", "222@s.whatsapp.net") + .build(), + ]) + .build(); + let response_node = NodeBuilder::new("response").children([list_node]).build(); + + let response = BlocklistResponse::try_from_node(&response_node).unwrap(); + assert_eq!(response.entries.len(), 2); + assert_eq!(response.entries[0].jid.user, "111"); + assert_eq!(response.entries[1].jid.user, "222"); + } + + #[test] + fn test_blocklist_response_direct_items() { + let response_node = NodeBuilder::new("response") + .children([ + NodeBuilder::new("item") + .attr("jid", "111@s.whatsapp.net") + .build(), + NodeBuilder::new("item") + .attr("jid", "222@s.whatsapp.net") + .build(), + ]) + .build(); + + let response = BlocklistResponse::try_from_node(&response_node).unwrap(); + assert_eq!(response.entries.len(), 2); + } + + #[test] + fn test_update_blocklist_spec_convenience_methods() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + + let block_spec = UpdateBlocklistSpec::block(&jid); + assert_eq!(block_spec.request.action, BlocklistAction::Block); + + let unblock_spec = UpdateBlocklistSpec::unblock(&jid); + assert_eq!(unblock_spec.request.action, BlocklistAction::Unblock); + } +} diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs new file mode 100644 index 000000000..9662e37a6 --- /dev/null +++ b/wacore/src/iq/contacts.rs @@ -0,0 +1,244 @@ +//! Contact-related IQ specifications. +//! +//! ## Profile Picture Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use anyhow::anyhow; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// Profile picture information. +#[derive(Debug, Clone)] +pub struct ProfilePicture { + /// Picture ID. + pub id: String, + /// URL to download the picture. + pub url: String, + /// Direct path for the picture (optional). + pub direct_path: Option, +} + +/// Profile picture type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ProfilePictureType { + /// Preview/thumbnail image. + #[default] + Preview, + /// Full-size image. + Full, +} + +impl ProfilePictureType { + fn as_str(&self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Full => "image", + } + } +} + +/// Profile picture IQ specification. +/// +/// Fetches the profile picture URL for a given JID. +#[derive(Debug, Clone)] +pub struct ProfilePictureSpec { + /// JID to get the profile picture for. + pub jid: Jid, + /// Whether to get preview or full image. + pub picture_type: ProfilePictureType, +} + +impl ProfilePictureSpec { + /// Create a new profile picture spec for preview image. + pub fn preview(jid: &Jid) -> Self { + Self { + jid: jid.clone(), + picture_type: ProfilePictureType::Preview, + } + } + + /// Create a new profile picture spec for full image. + pub fn full(jid: &Jid) -> Self { + Self { + jid: jid.clone(), + picture_type: ProfilePictureType::Full, + } + } + + /// Create a new profile picture spec with custom type. + pub fn new(jid: &Jid, picture_type: ProfilePictureType) -> Self { + Self { + jid: jid.clone(), + picture_type, + } + } +} + +impl IqSpec for ProfilePictureSpec { + type Response = Option; + + fn build_iq(&self) -> InfoQuery<'static> { + let picture_node = NodeBuilder::new("picture") + .attr("type", self.picture_type.as_str()) + .attr("query", "url") + .build(); + + InfoQuery::get( + "w:profile:picture", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![picture_node])), + ) + .with_target(self.jid.clone()) + } + + fn parse_response(&self, response: &Node) -> Result { + let picture_node = match response.get_optional_child("picture") { + Some(p) => p, + None => return Ok(None), + }; + + // Check for error response + if let Some(error_node) = picture_node.get_optional_child("error") { + let code = error_node.attrs().optional_string("code").unwrap_or("0"); + if code == "404" || code == "401" { + return Ok(None); + } + let text = error_node + .attrs() + .optional_string("text") + .unwrap_or("unknown error"); + return Err(anyhow!("Profile picture error {}: {}", code, text)); + } + + let id = picture_node + .attrs() + .optional_string("id") + .map(|s| s.to_string()) + .unwrap_or_default(); + + let url = picture_node + .attrs() + .optional_string("url") + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Picture response missing 'url' attribute"))?; + + let direct_path = picture_node + .attrs() + .optional_string("direct_path") + .map(|s| s.to_string()); + + Ok(Some(ProfilePicture { + id, + url, + direct_path, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_profile_picture_spec_preview() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid); + + assert_eq!(spec.picture_type, ProfilePictureType::Preview); + + let iq = spec.build_iq(); + assert_eq!(iq.namespace, "w:profile:picture"); + assert_eq!(iq.target, Some(jid)); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].tag, "picture"); + assert_eq!(nodes[0].attrs.get("type").map(|s| s.as_str()), Some("preview")); + } + } + + #[test] + fn test_profile_picture_spec_full() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::full(&jid); + + assert_eq!(spec.picture_type, ProfilePictureType::Full); + + let iq = spec.build_iq(); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].attrs.get("type").map(|s| s.as_str()), Some("image")); + } + } + + #[test] + fn test_profile_picture_spec_parse_success() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("picture") + .attr("id", "123456789") + .attr("url", "https://example.com/pic.jpg") + .attr("direct_path", "/v/pic.jpg") + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.is_some()); + + let pic = result.unwrap(); + assert_eq!(pic.id, "123456789"); + assert_eq!(pic.url, "https://example.com/pic.jpg"); + assert_eq!(pic.direct_path, Some("/v/pic.jpg".to_string())); + } + + #[test] + fn test_profile_picture_spec_parse_not_found() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("picture") + .children([NodeBuilder::new("error") + .attr("code", "404") + .attr("text", "item-not-found") + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_profile_picture_spec_parse_no_picture_node() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid); + + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.is_none()); + } +} diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs new file mode 100644 index 000000000..a9f89600f --- /dev/null +++ b/wacore/src/iq/groups.rs @@ -0,0 +1,796 @@ +use crate::StringEnum; +use crate::iq::node::{optional_attr, required_attr, required_child}; +use crate::iq::spec::IqSpec; +use crate::protocol::ProtocolNode; +use crate::request::InfoQuery; +use anyhow::{Result, anyhow}; +use typed_builder::TypedBuilder; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{GROUP_SERVER, Jid}; +use wacore_binary::node::{Node, NodeContent}; + +// Re-export AddressingMode from types::message for convenience +pub use crate::types::message::AddressingMode; + +// ============================================================================ +// Constants (from WhatsApp Web A/B props) +// ============================================================================ + +/// IQ namespace for group operations. +pub const GROUP_IQ_NAMESPACE: &str = "w:g2"; + +/// Maximum length for a WhatsApp group subject (from `group_max_subject` A/B prop). +pub const GROUP_SUBJECT_MAX_LENGTH: usize = 100; + +/// Maximum length for a WhatsApp group description (from `group_description_length` A/B prop). +pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 512; + +/// Maximum number of participants in a group (from `group_size_limit` A/B prop). +pub const GROUP_SIZE_LIMIT: usize = 257; + +// ============================================================================ +// Enums with StringEnum derive +// ============================================================================ + +/// Member link mode for group invite links. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum MemberLinkMode { + #[str = "admin_link"] + AdminLink, + #[str = "all_member_link"] + AllMemberLink, +} + +/// Member add mode for who can add participants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum MemberAddMode { + #[str = "admin_add"] + AdminAdd, + #[str = "all_member_add"] + AllMemberAdd, +} + +/// Membership approval mode for join requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum MembershipApprovalMode { + #[string_default] + #[str = "off"] + Off, + #[str = "on"] + On, +} + +/// Query request type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum GroupQueryRequestType { + #[string_default] + #[str = "interactive"] + Interactive, +} + +/// Participant type (admin level). +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] +pub enum ParticipantType { + #[string_default] + #[str = "member"] + Member, + #[str = "admin"] + Admin, + #[str = "superadmin"] + SuperAdmin, +} + +impl ParticipantType { + pub fn is_admin(&self) -> bool { + matches!(self, ParticipantType::Admin | ParticipantType::SuperAdmin) + } +} + +impl TryFrom> for ParticipantType { + type Error = anyhow::Error; + + fn try_from(value: Option<&str>) -> Result { + match value { + Some("admin") => Ok(ParticipantType::Admin), + Some("superadmin") => Ok(ParticipantType::SuperAdmin), + Some("member") | None => Ok(ParticipantType::Member), + Some(other) => Err(anyhow!("unknown participant type: {other}")), + } + } +} + +// ============================================================================ +// Validated Newtypes +// ============================================================================ + +/// A validated group subject string. +/// +/// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupSubject(String); + +impl GroupSubject { + /// Create a new validated group subject. + pub fn new(subject: impl Into) -> Result { + let s = subject.into(); + if s.chars().count() > GROUP_SUBJECT_MAX_LENGTH { + return Err(anyhow!( + "Group subject exceeds {} characters", + GROUP_SUBJECT_MAX_LENGTH + )); + } + Ok(Self(s)) + } + + /// Create a group subject without validation (for parsing responses). + pub fn new_unchecked(subject: impl Into) -> Self { + Self(subject.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +/// A validated group description string. +/// +/// WhatsApp limits group descriptions to [`GROUP_DESCRIPTION_MAX_LENGTH`] characters. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GroupDescription(String); + +impl GroupDescription { + /// Create a new validated group description. + pub fn new(description: impl Into) -> Result { + let s = description.into(); + if s.chars().count() > GROUP_DESCRIPTION_MAX_LENGTH { + return Err(anyhow!( + "Group description exceeds {} characters", + GROUP_DESCRIPTION_MAX_LENGTH + )); + } + Ok(Self(s)) + } + + /// Create a group description without validation (for parsing responses). + pub fn new_unchecked(description: impl Into) -> Self { + Self(description.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +// ============================================================================ +// Group Create Options (with TypedBuilder) +// ============================================================================ + +/// Options for a participant when creating a group. +#[derive(Debug, Clone, TypedBuilder)] +#[builder(build_method(into))] +pub struct GroupParticipantOptions { + pub jid: Jid, + #[builder(default, setter(strip_option))] + pub phone_number: Option, + #[builder(default, setter(strip_option))] + pub privacy: Option>, +} + +impl GroupParticipantOptions { + pub fn new(jid: Jid) -> Self { + Self { + jid, + phone_number: None, + privacy: None, + } + } + + pub fn from_phone(phone_number: Jid) -> Self { + Self::new(phone_number) + } + + pub fn from_lid_and_phone(lid: Jid, phone_number: Jid) -> Self { + Self::new(lid).with_phone_number(phone_number) + } + + pub fn with_phone_number(mut self, phone_number: Jid) -> Self { + self.phone_number = Some(phone_number); + self + } + + pub fn with_privacy(mut self, privacy: Vec) -> Self { + self.privacy = Some(privacy); + self + } +} + +/// Options for creating a new group. +#[derive(Debug, Clone, TypedBuilder)] +#[builder(build_method(into))] +pub struct GroupCreateOptions { + #[builder(setter(into))] + pub subject: String, + #[builder(default)] + pub participants: Vec, + #[builder(default = Some(MemberLinkMode::AdminLink), setter(strip_option))] + pub member_link_mode: Option, + #[builder(default = Some(MemberAddMode::AllMemberAdd), setter(strip_option))] + pub member_add_mode: Option, + #[builder(default = Some(MembershipApprovalMode::Off), setter(strip_option))] + pub membership_approval_mode: Option, + #[builder(default = Some(0), setter(strip_option))] + pub ephemeral_expiration: Option, +} + +impl GroupCreateOptions { + /// Create new options with just a subject (for backwards compatibility). + pub fn new(subject: impl Into) -> Self { + Self { + subject: subject.into(), + ..Default::default() + } + } + + pub fn with_participant(mut self, participant: GroupParticipantOptions) -> Self { + self.participants.push(participant); + self + } + + pub fn with_participants(mut self, participants: Vec) -> Self { + self.participants = participants; + self + } + + pub fn with_member_link_mode(mut self, mode: MemberLinkMode) -> Self { + self.member_link_mode = Some(mode); + self + } + + pub fn with_member_add_mode(mut self, mode: MemberAddMode) -> Self { + self.member_add_mode = Some(mode); + self + } + + pub fn with_membership_approval_mode(mut self, mode: MembershipApprovalMode) -> Self { + self.membership_approval_mode = Some(mode); + self + } + + pub fn with_ephemeral_expiration(mut self, expiration: u32) -> Self { + self.ephemeral_expiration = Some(expiration); + self + } +} + +impl Default for GroupCreateOptions { + fn default() -> Self { + Self { + subject: String::new(), + participants: Vec::new(), + member_link_mode: Some(MemberLinkMode::AdminLink), + member_add_mode: Some(MemberAddMode::AllMemberAdd), + membership_approval_mode: Some(MembershipApprovalMode::Off), + ephemeral_expiration: Some(0), + } + } +} + +/// Normalize participants: drop phone_number for non-LID JIDs. +pub fn normalize_participants( + participants: &[GroupParticipantOptions], +) -> Vec { + participants + .iter() + .cloned() + .map(|p| { + if !p.jid.is_lid() && p.phone_number.is_some() { + GroupParticipantOptions { + phone_number: None, + ..p + } + } else { + p + } + }) + .collect() +} + +/// Build the `` node for group creation. +pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { + let mut children = Vec::new(); + + if let Some(link_mode) = &options.member_link_mode { + children.push( + NodeBuilder::new("member_link_mode") + .string_content(link_mode.as_str()) + .build(), + ); + } + + if let Some(add_mode) = &options.member_add_mode { + children.push( + NodeBuilder::new("member_add_mode") + .string_content(add_mode.as_str()) + .build(), + ); + } + + for participant in &options.participants { + let mut attrs = vec![("jid", participant.jid.to_string())]; + if let Some(pn) = &participant.phone_number { + attrs.push(("phone_number", pn.to_string())); + } + + let participant_node = if let Some(privacy_bytes) = &participant.privacy { + NodeBuilder::new("participant") + .attrs(attrs) + .children([NodeBuilder::new("privacy") + .string_content(hex::encode(privacy_bytes)) + .build()]) + .build() + } else { + NodeBuilder::new("participant").attrs(attrs).build() + }; + children.push(participant_node); + } + + if let Some(expiration) = &options.ephemeral_expiration { + children.push( + NodeBuilder::new("ephemeral") + .attr("expiration", expiration.to_string()) + .build(), + ); + } + + if let Some(approval_mode) = &options.membership_approval_mode { + children.push( + NodeBuilder::new("membership_approval_mode") + .children([NodeBuilder::new("group_join") + .attr("state", approval_mode.as_str()) + .build()]) + .build(), + ); + } + + NodeBuilder::new("create") + .attr("subject", &options.subject) + .children(children) + .build() +} + +// ============================================================================ +// Query Request/Response Types +// ============================================================================ + +/// Request to query group information. +#[derive(Debug, Clone, Default)] +pub struct GroupQueryRequest { + pub request: GroupQueryRequestType, +} + +impl ProtocolNode for GroupQueryRequest { + fn tag(&self) -> &'static str { + "query" + } + + fn into_node(self) -> Node { + NodeBuilder::new("query") + .attr("request", self.request.as_str()) + .build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "query" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + Ok(Self::default()) + } +} + +/// A participant in a group response. +#[derive(Debug, Clone)] +pub struct GroupParticipantResponse { + pub jid: Jid, + pub phone_number: Option, + pub participant_type: ParticipantType, +} + +impl ProtocolNode for GroupParticipantResponse { + fn tag(&self) -> &'static str { + "participant" + } + + fn into_node(self) -> Node { + let mut builder = NodeBuilder::new("participant").attr("jid", self.jid.to_string()); + if let Some(pn) = &self.phone_number { + builder = builder.attr("phone_number", pn.to_string()); + } + if self.participant_type != ParticipantType::Member { + builder = builder.attr("type", self.participant_type.as_str()); + } + builder.build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "participant" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + let jid = node + .attrs() + .optional_jid("jid") + .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?; + let phone_number = node.attrs().optional_jid("phone_number"); + let participant_type = ParticipantType::try_from(node.attrs().optional_string("type"))?; + + Ok(Self { + jid, + phone_number, + participant_type, + }) + } +} + +/// Response from a group info query. +#[derive(Debug, Clone)] +pub struct GroupInfoResponse { + pub id: Jid, + pub subject: GroupSubject, + pub addressing_mode: AddressingMode, + pub participants: Vec, +} + +impl ProtocolNode for GroupInfoResponse { + fn tag(&self) -> &'static str { + "group" + } + + fn into_node(self) -> Node { + let children: Vec = self + .participants + .into_iter() + .map(|p| p.into_node()) + .collect(); + NodeBuilder::new("group") + .attr("id", self.id.to_string()) + .attr("subject", self.subject.as_str()) + .attr("addressing_mode", self.addressing_mode.as_str()) + .children(children) + .build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "group" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + + let id_str = required_attr(node, "id")?; + let id = if id_str.contains('@') { + id_str.parse()? + } else { + Jid::group(id_str) + }; + + let subject = + GroupSubject::new_unchecked(optional_attr(node, "subject").unwrap_or_default()); + + let addressing_mode = + AddressingMode::try_from(optional_attr(node, "addressing_mode").unwrap_or("pn"))?; + + let participants = node + .get_children_by_tag("participant") + .iter() + .map(|child| GroupParticipantResponse::try_from_node(child)) + .collect::>>()?; + + Ok(Self { + id, + subject, + addressing_mode, + participants, + }) + } +} + +// ============================================================================ +// Participating Request/Response Types +// ============================================================================ + +/// Request to get all groups the user is participating in. +#[derive(Debug, Clone)] +pub struct GroupParticipatingRequest { + pub include_participants: bool, + pub include_description: bool, +} + +impl GroupParticipatingRequest { + pub fn new() -> Self { + Self { + include_participants: true, + include_description: true, + } + } +} + +impl Default for GroupParticipatingRequest { + fn default() -> Self { + Self::new() + } +} + +impl ProtocolNode for GroupParticipatingRequest { + fn tag(&self) -> &'static str { + "participating" + } + + fn into_node(self) -> Node { + let mut children = Vec::new(); + if self.include_participants { + children.push(NodeBuilder::new("participants").build()); + } + if self.include_description { + children.push(NodeBuilder::new("description").build()); + } + NodeBuilder::new("participating").children(children).build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "participating" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + Ok(Self::default()) + } +} + +/// Response containing all groups the user is participating in. +#[derive(Debug, Clone, Default)] +pub struct GroupParticipatingResponse { + pub groups: Vec, +} + +impl ProtocolNode for GroupParticipatingResponse { + fn tag(&self) -> &'static str { + "groups" + } + + fn into_node(self) -> Node { + let children: Vec = self.groups.into_iter().map(|g| g.into_node()).collect(); + NodeBuilder::new("groups").children(children).build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "groups" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + + let groups = node + .get_children_by_tag("group") + .iter() + .map(|child| GroupInfoResponse::try_from_node(child)) + .collect::>>()?; + + Ok(Self { groups }) + } +} + +// ============================================================================ +// IqSpec Implementations +// ============================================================================ + +/// IQ specification for querying a specific group's info. +#[derive(Debug, Clone)] +pub struct GroupQueryIq { + pub group_jid: Jid, +} + +impl GroupQueryIq { + pub fn new(group_jid: Jid) -> Self { + Self { group_jid } + } +} + +impl IqSpec for GroupQueryIq { + type Response = GroupInfoResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get( + GROUP_IQ_NAMESPACE, + self.group_jid.clone(), + Some(NodeContent::Nodes(vec![ + GroupQueryRequest::default().into_node(), + ])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let group_node = required_child(response, "group")?; + GroupInfoResponse::try_from_node(group_node) + } +} + +/// IQ specification for getting all groups the user is participating in. +#[derive(Debug, Clone, Default)] +pub struct GroupParticipatingIq; + +impl GroupParticipatingIq { + pub fn new() -> Self { + Self + } +} + +impl IqSpec for GroupParticipatingIq { + type Response = GroupParticipatingResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get( + GROUP_IQ_NAMESPACE, + Jid::new("", GROUP_SERVER), + Some(NodeContent::Nodes(vec![ + GroupParticipatingRequest::new().into_node(), + ])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let groups_node = required_child(response, "groups")?; + GroupParticipatingResponse::try_from_node(groups_node) + } +} + +/// IQ specification for creating a new group. +#[derive(Debug, Clone)] +pub struct GroupCreateIq { + pub options: GroupCreateOptions, +} + +impl GroupCreateIq { + pub fn new(options: GroupCreateOptions) -> Self { + Self { options } + } +} + +impl IqSpec for GroupCreateIq { + type Response = Jid; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::set( + GROUP_IQ_NAMESPACE, + Jid::new("", GROUP_SERVER), + Some(NodeContent::Nodes(vec![build_create_group_node( + &self.options, + )])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let group_node = required_child(response, "group")?; + let group_id_str = required_attr(group_node, "id")?; + + if group_id_str.contains('@') { + group_id_str.parse().map_err(Into::into) + } else { + Ok(Jid::group(group_id_str)) + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_group_subject_validation() { + let subject = GroupSubject::new("Test Group").unwrap(); + assert_eq!(subject.as_str(), "Test Group"); + + let at_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH); + assert!(GroupSubject::new(&at_limit).is_ok()); + + let over_limit = "a".repeat(GROUP_SUBJECT_MAX_LENGTH + 1); + assert!(GroupSubject::new(&over_limit).is_err()); + } + + #[test] + fn test_group_description_validation() { + let desc = GroupDescription::new("Test Description").unwrap(); + assert_eq!(desc.as_str(), "Test Description"); + + let at_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH); + assert!(GroupDescription::new(&at_limit).is_ok()); + + let over_limit = "a".repeat(GROUP_DESCRIPTION_MAX_LENGTH + 1); + assert!(GroupDescription::new(&over_limit).is_err()); + } + + #[test] + fn test_string_enum_member_add_mode() { + assert_eq!(MemberAddMode::AdminAdd.as_str(), "admin_add"); + assert_eq!(MemberAddMode::AllMemberAdd.as_str(), "all_member_add"); + assert_eq!( + MemberAddMode::try_from("admin_add").unwrap(), + MemberAddMode::AdminAdd + ); + assert!(MemberAddMode::try_from("invalid").is_err()); + } + + #[test] + fn test_string_enum_member_link_mode() { + assert_eq!(MemberLinkMode::AdminLink.as_str(), "admin_link"); + assert_eq!(MemberLinkMode::AllMemberLink.as_str(), "all_member_link"); + assert_eq!( + MemberLinkMode::try_from("admin_link").unwrap(), + MemberLinkMode::AdminLink + ); + } + + #[test] + fn test_participant_type_is_admin() { + assert!(!ParticipantType::Member.is_admin()); + assert!(ParticipantType::Admin.is_admin()); + assert!(ParticipantType::SuperAdmin.is_admin()); + } + + #[test] + fn test_normalize_participants_drops_phone_for_pn() { + let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap(); + let lid_jid: Jid = "100000000000001@lid".parse().unwrap(); + let phone_jid: Jid = "15550000001@s.whatsapp.net".parse().unwrap(); + + let participants = vec![ + GroupParticipantOptions::new(pn_jid.clone()).with_phone_number(phone_jid.clone()), + GroupParticipantOptions::new(lid_jid.clone()).with_phone_number(phone_jid.clone()), + ]; + + let normalized = normalize_participants(&participants); + assert!(normalized[0].phone_number.is_none()); + assert_eq!(normalized[0].jid, pn_jid); + assert_eq!(normalized[1].phone_number.as_ref(), Some(&phone_jid)); + } + + #[test] + fn test_build_create_group_node() { + let pn_jid: Jid = "15551234567@s.whatsapp.net".parse().unwrap(); + let options = GroupCreateOptions::new("Test Subject") + .with_participant(GroupParticipantOptions::from_phone(pn_jid)) + .with_member_link_mode(MemberLinkMode::AllMemberLink) + .with_member_add_mode(MemberAddMode::AdminAdd); + + let node = build_create_group_node(&options); + assert_eq!(node.tag, "create"); + assert_eq!( + node.attrs().optional_string("subject"), + Some("Test Subject") + ); + + let link_mode = node.get_children_by_tag("member_link_mode")[0]; + assert_eq!( + link_mode.content.as_ref().and_then(|c| match c { + NodeContent::String(s) => Some(s.as_str()), + _ => None, + }), + Some("all_member_link") + ); + } + + #[test] + fn test_typed_builder() { + let options: GroupCreateOptions = GroupCreateOptions::builder() + .subject("My Group") + .member_add_mode(MemberAddMode::AdminAdd) + .build(); + + assert_eq!(options.subject, "My Group"); + assert_eq!(options.member_add_mode, Some(MemberAddMode::AdminAdd)); + } +} diff --git a/wacore/src/iq/keepalive.rs b/wacore/src/iq/keepalive.rs new file mode 100644 index 000000000..933e70052 --- /dev/null +++ b/wacore/src/iq/keepalive.rs @@ -0,0 +1,67 @@ +//! Keepalive IQ specification. +//! +//! Wire format: +//! ```xml +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::Node; + +/// Keepalive ping IQ specification. +/// +/// This is a simple ping to keep the connection alive. The server responds +/// with an empty result to confirm the connection is still active. +#[derive(Debug, Clone, Default)] +pub struct KeepaliveSpec; + +impl KeepaliveSpec { + /// Create a new keepalive spec. + pub fn new() -> Self { + Self + } +} + +impl IqSpec for KeepaliveSpec { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get("w:p", Jid::new("", SERVER_JID), None) + } + + fn parse_response(&self, _response: &Node) -> Result { + // Keepalive just needs a successful response, no parsing needed + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wacore_binary::builder::NodeBuilder; + + #[test] + fn test_keepalive_spec_build_iq() { + let spec = KeepaliveSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "w:p"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + assert!(iq.content.is_none()); + } + + #[test] + fn test_keepalive_spec_parse_response() { + let spec = KeepaliveSpec::new(); + let response = NodeBuilder::new("iq").build(); + + let result = spec.parse_response(&response); + assert!(result.is_ok()); + } +} diff --git a/wacore/src/iq/mediaconn.rs b/wacore/src/iq/mediaconn.rs new file mode 100644 index 000000000..123fb0811 --- /dev/null +++ b/wacore/src/iq/mediaconn.rs @@ -0,0 +1,162 @@ +//! Media connection IQ specification. +//! +//! Wire format: +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use anyhow::anyhow; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// Media connection host information. +#[derive(Debug, Clone)] +pub struct MediaConnHost { + /// The hostname for media uploads/downloads. + pub hostname: String, +} + +/// Media connection response containing auth token and hosts. +#[derive(Debug, Clone)] +pub struct MediaConnResponse { + /// Authentication token for media operations. + pub auth: String, + /// Time-to-live in seconds for this connection info. + pub ttl: u64, + /// Maximum number of buckets (optional). + pub max_buckets: Option, + /// List of available media hosts. + pub hosts: Vec, +} + +/// Media connection IQ specification. +/// +/// Requests media server connection details including authentication token +/// and available hosts for uploading/downloading media. +#[derive(Debug, Clone, Default)] +pub struct MediaConnSpec; + +impl MediaConnSpec { + /// Create a new media connection spec. + pub fn new() -> Self { + Self + } +} + +impl IqSpec for MediaConnSpec { + type Response = MediaConnResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let media_conn_node = NodeBuilder::new("media_conn").build(); + + InfoQuery::set( + "w:m", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![media_conn_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let media_conn_node = response + .get_optional_child("media_conn") + .ok_or_else(|| anyhow!("Missing media_conn node in response"))?; + + let mut attrs = media_conn_node.attrs(); + let auth = attrs.string("auth"); + let ttl = attrs.optional_u64("ttl").unwrap_or(0); + let max_buckets = attrs.optional_u64("max_buckets"); + + let hosts = media_conn_node + .get_children_by_tag("host") + .iter() + .map(|host_node| MediaConnHost { + hostname: host_node.attrs().string("hostname"), + }) + .collect(); + + Ok(MediaConnResponse { + auth, + ttl, + max_buckets, + hosts, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_media_conn_spec_build_iq() { + let spec = MediaConnSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "w:m"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + assert!(iq.content.is_some()); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "media_conn"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_media_conn_spec_parse_response() { + let spec = MediaConnSpec::new(); + + // Build a mock response + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("media_conn") + .attr("auth", "test-auth-token") + .attr("ttl", "3600") + .attr("max_buckets", "4") + .children([ + NodeBuilder::new("host") + .attr("hostname", "mmg.whatsapp.net") + .build(), + NodeBuilder::new("host") + .attr("hostname", "mmg-fna.whatsapp.net") + .build(), + ]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + + assert_eq!(result.auth, "test-auth-token"); + assert_eq!(result.ttl, 3600); + assert_eq!(result.max_buckets, Some(4)); + assert_eq!(result.hosts.len(), 2); + assert_eq!(result.hosts[0].hostname, "mmg.whatsapp.net"); + assert_eq!(result.hosts[1].hostname, "mmg-fna.whatsapp.net"); + } + + #[test] + fn test_media_conn_spec_parse_response_missing_node() { + let spec = MediaConnSpec::new(); + + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response); + assert!(result.is_err()); + } +} diff --git a/wacore/src/iq/mex.rs b/wacore/src/iq/mex.rs new file mode 100644 index 000000000..67759ed9d --- /dev/null +++ b/wacore/src/iq/mex.rs @@ -0,0 +1,259 @@ +//! MEX (Meta Exchange) GraphQL IQ specification. +//! +//! MEX is WhatsApp's GraphQL API for querying user data, contact information, +//! and other Meta-related services. +//! +//! Wire format: +//! ```xml +//! +//! +//! {"variables":{...}} +//! +//! +//! +//! +//! {"data":{...},"errors":[...]} +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// MEX GraphQL error extensions. +#[derive(Debug, Clone, Deserialize)] +pub struct MexErrorExtensions { + /// Error code from the server. + pub error_code: Option, + /// Whether this is a summary/fatal error. + pub is_summary: Option, + /// Whether the request can be retried. + #[serde(default)] + pub is_retryable: Option, + /// Severity level of the error. + pub severity: Option, +} + +/// MEX GraphQL error. +#[derive(Debug, Clone, Deserialize)] +pub struct MexGraphQLError { + /// Error message. + pub message: String, + /// Error extensions with additional metadata. + pub extensions: Option, +} + +impl MexGraphQLError { + /// Get the error code if available. + #[inline] + pub fn error_code(&self) -> Option { + self.extensions.as_ref()?.error_code + } + + /// Check if this is a fatal error. + #[inline] + pub fn is_fatal(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|ext| ext.is_summary == Some(true)) + } +} + +/// MEX GraphQL response. +#[derive(Debug, Clone, Deserialize)] +pub struct MexResponse { + /// Response data (if successful). + pub data: Option, + /// List of errors (if any). + pub errors: Option>, +} + +impl MexResponse { + /// Check if the response contains data. + #[inline] + pub fn has_data(&self) -> bool { + self.data.is_some() + } + + /// Check if the response contains errors. + #[inline] + pub fn has_errors(&self) -> bool { + self.errors.as_ref().is_some_and(|e| !e.is_empty()) + } + + /// Find the fatal error if present. + pub fn fatal_error(&self) -> Option<&MexGraphQLError> { + self.errors.as_ref()?.iter().find(|e| e.is_fatal()) + } +} + +/// Internal payload structure for MEX requests. +#[derive(Serialize)] +struct MexPayload<'a> { + variables: &'a Value, +} + +/// MEX GraphQL query IQ specification. +#[derive(Debug, Clone)] +pub struct MexQuerySpec { + /// The GraphQL document ID (query_id). + pub doc_id: String, + /// Variables for the GraphQL query. + pub variables: Value, +} + +impl MexQuerySpec { + /// Create a new MEX query spec. + pub fn new(doc_id: impl Into, variables: Value) -> Self { + Self { + doc_id: doc_id.into(), + variables, + } + } +} + +impl IqSpec for MexQuerySpec { + type Response = MexResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let payload = MexPayload { + variables: &self.variables, + }; + let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default(); + + let query_node = NodeBuilder::new("query") + .attr("query_id", &self.doc_id) + .bytes(payload_bytes) + .build(); + + InfoQuery::get( + "w:mex", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![query_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let result_node = response + .get_optional_child("result") + .ok_or_else(|| anyhow!("Missing node in MEX response"))?; + + let result_bytes = match &result_node.content { + Some(NodeContent::Bytes(bytes)) => bytes, + _ => return Err(anyhow!("MEX result node content is not binary")), + }; + + let mex_response: MexResponse = serde_json::from_slice(result_bytes)?; + + // Check for fatal errors + if let Some(fatal) = mex_response.fatal_error() { + let code = fatal.error_code().unwrap_or(500); + return Err(anyhow!( + "MEX fatal error (code={}): {}", + code, + fatal.message + )); + } + + Ok(mex_response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_mex_query_spec_build_iq() { + let spec = MexQuerySpec::new( + "29829202653362039", + json!({ + "input": {"query_input": [{"jid": "1234@s.whatsapp.net"}]}, + "include_username": true + }), + ); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "w:mex"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + assert!(iq.content.is_some()); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "query"); + assert_eq!( + nodes[0].attrs.get("query_id").map(|s| s.as_str()), + Some("29829202653362039") + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_mex_response_deserialization() { + let json_str = r#"{ + "data": { + "xwa2_fetch_wa_users": [ + {"jid": "1234567890@s.whatsapp.net", "country_code": "1"} + ] + } + }"#; + + let response: MexResponse = serde_json::from_str(json_str).unwrap(); + assert!(response.has_data()); + assert!(!response.has_errors()); + assert!(response.fatal_error().is_none()); + } + + #[test] + fn test_mex_response_with_fatal_error() { + let json_str = r#"{ + "data": null, + "errors": [ + { + "message": "Fatal server error", + "extensions": { + "error_code": 500, + "is_summary": true, + "severity": "CRITICAL" + } + } + ] + }"#; + + let response: MexResponse = serde_json::from_str(json_str).unwrap(); + assert!(!response.has_data()); + assert!(response.has_errors()); + + let fatal = response.fatal_error(); + assert!(fatal.is_some()); + + let fatal = fatal.unwrap(); + assert_eq!(fatal.message, "Fatal server error"); + assert_eq!(fatal.error_code(), Some(500)); + assert!(fatal.is_fatal()); + } + + #[test] + fn test_mex_graphql_error_methods() { + let error = MexGraphQLError { + message: "Test error".to_string(), + extensions: Some(MexErrorExtensions { + error_code: Some(404), + is_summary: Some(false), + is_retryable: Some(true), + severity: Some("WARNING".to_string()), + }), + }; + + assert_eq!(error.error_code(), Some(404)); + assert!(!error.is_fatal()); + } +} diff --git a/wacore/src/iq/mod.rs b/wacore/src/iq/mod.rs new file mode 100644 index 000000000..a6c3ab362 --- /dev/null +++ b/wacore/src/iq/mod.rs @@ -0,0 +1,11 @@ +pub mod blocklist; +pub mod contacts; +pub mod groups; +pub mod keepalive; +pub mod mediaconn; +pub mod mex; +pub mod node; +pub mod prekeys; +pub mod spam_report; +pub mod spec; +pub mod usync; diff --git a/wacore/src/iq/node.rs b/wacore/src/iq/node.rs new file mode 100644 index 000000000..71e954ec9 --- /dev/null +++ b/wacore/src/iq/node.rs @@ -0,0 +1,51 @@ +//! Helper functions for parsing protocol nodes in IQ responses. +//! +//! These functions provide a consistent way to extract required and optional +//! children/attributes from protocol nodes with clear error messages. + +use anyhow::anyhow; +use wacore_binary::jid::Jid; +use wacore_binary::node::Node; + +/// Get a required child node by tag, returning an error if not found. +pub fn required_child<'a>(node: &'a Node, tag: &str) -> Result<&'a Node, anyhow::Error> { + node.get_optional_child(tag) + .ok_or_else(|| anyhow!("<{tag}> child not found")) +} + +/// Get an optional child node by tag. +pub fn optional_child<'a>(node: &'a Node, tag: &str) -> Option<&'a Node> { + node.get_optional_child(tag) +} + +/// Get a required string attribute, returning an error if not found. +pub fn required_attr(node: &Node, key: &str) -> Result { + node.attrs() + .optional_string(key) + .map(str::to_string) + .ok_or_else(|| anyhow!("missing required attribute {key}")) +} + +/// Get an optional string attribute. +pub fn optional_attr<'a>(node: &'a Node, key: &str) -> Option<&'a str> { + node.attrs().optional_string(key) +} + +/// Get an optional u64 attribute. +pub fn optional_u64(node: &Node, key: &str) -> Option { + node.attrs().optional_u64(key) +} + +/// Get a required JID attribute, returning an error if not found or invalid. +pub fn required_jid(node: &Node, key: &str) -> Result { + let value = required_attr(node, key)?; + value.parse().map_err(|err| anyhow!("{err}")) +} + +/// Get an optional JID attribute, returning an error only if the value is invalid. +pub fn optional_jid(node: &Node, key: &str) -> Result, anyhow::Error> { + match optional_attr(node, key) { + Some(value) => Ok(Some(value.parse().map_err(|err| anyhow!("{err}"))?)), + None => Ok(None), + } +} diff --git a/wacore/src/iq/prekeys.rs b/wacore/src/iq/prekeys.rs new file mode 100644 index 000000000..4ca08c802 --- /dev/null +++ b/wacore/src/iq/prekeys.rs @@ -0,0 +1,217 @@ +//! Pre-key IQ specifications. +//! +//! ## Fetch Pre-Keys Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ... +//! ... +//! ... +//! ...... +//! ......... +//! +//! +//! +//! ``` +//! +//! ## Pre-Key Count Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::prekeys::PreKeyUtils; +use crate::request::InfoQuery; +use anyhow::anyhow; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +// Re-export PreKeyBundle for convenience +pub use crate::libsignal::protocol::PreKeyBundle; + +/// Pre-key count response. +#[derive(Debug, Clone)] +pub struct PreKeyCountResponse { + /// Number of pre-keys available on the server. + pub count: usize, +} + +/// Pre-key count IQ specification. +/// +/// Queries the server for how many pre-keys are currently stored for this device. +#[derive(Debug, Clone, Default)] +pub struct PreKeyCountSpec; + +impl PreKeyCountSpec { + /// Create a new pre-key count spec. + pub fn new() -> Self { + Self + } +} + +impl IqSpec for PreKeyCountSpec { + type Response = PreKeyCountResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let count_node = NodeBuilder::new("count").build(); + + InfoQuery::get( + "encrypt", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![count_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let count_node = response + .get_optional_child("count") + .ok_or_else(|| anyhow!("Missing node in response"))?; + + let count_str = count_node + .attrs() + .optional_string("value") + .unwrap_or("0"); + let count = count_str.parse::().unwrap_or(0); + + Ok(PreKeyCountResponse { count }) + } +} + +/// Pre-key fetch IQ specification. +/// +/// Fetches pre-key bundles for a list of JIDs from the server. +#[derive(Debug, Clone)] +pub struct PreKeyFetchSpec { + /// JIDs to fetch pre-keys for. + pub jids: Vec, + /// Optional reason for the fetch (e.g., "retry"). + pub reason: Option, +} + +impl PreKeyFetchSpec { + /// Create a new pre-key fetch spec. + pub fn new(jids: Vec) -> Self { + Self { jids, reason: None } + } + + /// Create a new pre-key fetch spec with a reason. + pub fn with_reason(jids: Vec, reason: impl Into) -> Self { + Self { + jids, + reason: Some(reason.into()), + } + } +} + +impl IqSpec for PreKeyFetchSpec { + type Response = std::collections::HashMap; + + fn build_iq(&self) -> InfoQuery<'static> { + let content = + PreKeyUtils::build_fetch_prekeys_request(&self.jids, self.reason.as_deref()); + + InfoQuery::get( + "encrypt", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![content])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + PreKeyUtils::parse_prekeys_response(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prekey_count_spec_build_iq() { + let spec = PreKeyCountSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "encrypt"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "count"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_prekey_count_spec_parse_response() { + let spec = PreKeyCountSpec::new(); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("count").attr("value", "42").build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.count, 42); + } + + #[test] + fn test_prekey_count_spec_parse_response_missing_value() { + let spec = PreKeyCountSpec::new(); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("count").build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.count, 0); // Default to 0 if missing + } + + #[test] + fn test_prekey_fetch_spec_build_iq() { + let jids = vec![ + "1234567890:0@s.whatsapp.net".parse().unwrap(), + "0987654321:0@s.whatsapp.net".parse().unwrap(), + ]; + let spec = PreKeyFetchSpec::new(jids); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "encrypt"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "key"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_prekey_fetch_spec_with_reason() { + let jids = vec!["1234567890:0@s.whatsapp.net".parse().unwrap()]; + let spec = PreKeyFetchSpec::with_reason(jids, "retry"); + + assert_eq!(spec.reason, Some("retry".to_string())); + } +} diff --git a/wacore/src/iq/spam_report.rs b/wacore/src/iq/spam_report.rs new file mode 100644 index 000000000..9c56aa030 --- /dev/null +++ b/wacore/src/iq/spam_report.rs @@ -0,0 +1,142 @@ +//! Spam report IQ specification. +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! ... +//! +//! +//! +//! +//! +//! +//! REPORT_123 +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use crate::types::spam_report::{SpamReportRequest, SpamReportResult, build_spam_list_node}; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +// Re-export types for convenience +pub use crate::types::spam_report::{SpamFlow, SpamReportRequest as Request, SpamReportResult as Response}; + +/// Spam report IQ specification. +/// +/// Sends a spam report for one or more messages to WhatsApp. +#[derive(Debug, Clone)] +pub struct SpamReportSpec { + /// The spam report request. + pub request: SpamReportRequest, +} + +impl SpamReportSpec { + /// Create a new spam report spec. + pub fn new(request: SpamReportRequest) -> Self { + Self { request } + } +} + +impl IqSpec for SpamReportSpec { + type Response = SpamReportResult; + + fn build_iq(&self) -> InfoQuery<'static> { + let spam_list_node = build_spam_list_node(&self.request); + + InfoQuery::set( + "spam", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![spam_list_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + // Extract report_id from response if present + let report_id = response + .get_optional_child_by_tag(&["report_id"]) + .and_then(|n| match &n.content { + Some(NodeContent::String(s)) => Some(s.clone()), + _ => None, + }); + + Ok(SpamReportResult { report_id }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::spam_report::SpamFlow; + use wacore_binary::builder::NodeBuilder; + + #[test] + fn test_spam_report_spec_build_iq() { + let request = SpamReportRequest { + message_id: "TEST123".to_string(), + message_timestamp: 1234567890, + spam_flow: SpamFlow::MessageMenu, + ..Default::default() + }; + + let spec = SpamReportSpec::new(request); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "spam"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "spam_list"); + assert_eq!( + nodes[0].attrs.get("spam_flow").map(|s| s.as_str()), + Some("MessageMenu") + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_spam_report_spec_parse_response_with_report_id() { + let request = SpamReportRequest { + message_id: "TEST123".to_string(), + message_timestamp: 1234567890, + spam_flow: SpamFlow::MessageMenu, + ..Default::default() + }; + + let spec = SpamReportSpec::new(request); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("report_id") + .string_content("REPORT_ABC123") + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.report_id, Some("REPORT_ABC123".to_string())); + } + + #[test] + fn test_spam_report_spec_parse_response_without_report_id() { + let request = SpamReportRequest { + message_id: "TEST123".to_string(), + message_timestamp: 1234567890, + spam_flow: SpamFlow::MessageMenu, + ..Default::default() + }; + + let spec = SpamReportSpec::new(request); + + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.report_id, None); + } +} diff --git a/wacore/src/iq/spec.rs b/wacore/src/iq/spec.rs new file mode 100644 index 000000000..d723819bf --- /dev/null +++ b/wacore/src/iq/spec.rs @@ -0,0 +1,17 @@ +use crate::request::InfoQuery; +use wacore_binary::node::Node; + +/// A reusable IQ specification that pairs a request builder with a response parser. +/// +/// This keeps protocol-level IQ logic in `wacore`, while runtime orchestration +/// (sending, retries, timeouts) stays in the main crate. +pub trait IqSpec { + /// The output type produced by parsing the IQ response. + type Response; + + /// Build the IQ stanza for this spec. + fn build_iq(&self) -> InfoQuery<'static>; + + /// Parse the IQ response node into the typed response. + fn parse_response(&self, response: &Node) -> Result; +} diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs new file mode 100644 index 000000000..4ec5f25d3 --- /dev/null +++ b/wacore/src/iq/usync.rs @@ -0,0 +1,1035 @@ +//! Usync IQ specifications. +//! +//! The usync protocol is used for user synchronization operations including: +//! - Checking if phone numbers are registered on WhatsApp +//! - Fetching contact information (LID, status, picture, business status) +//! - Fetching user information by JID +//! - Fetching device lists +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +1234567890 +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! Hello World +//! +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use anyhow::anyhow; +use std::collections::HashMap; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// Usync mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UsyncMode { + /// Query mode - used for contact lookups. + #[default] + Query, + /// Full mode - used for user info with more details. + Full, +} + +impl UsyncMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Query => "query", + Self::Full => "full", + } + } +} + +/// Usync context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UsyncContext { + /// Interactive context - for user-initiated operations. + #[default] + Interactive, + /// Background context - for background sync operations. + Background, + /// Message context - for message-related operations. + Message, +} + +impl UsyncContext { + pub fn as_str(&self) -> &'static str { + match self { + Self::Interactive => "interactive", + Self::Background => "background", + Self::Message => "message", + } + } +} + +/// Result of checking if a phone number is on WhatsApp. +#[derive(Debug, Clone)] +pub struct IsOnWhatsAppResult { + /// The JID of the user (if registered). + pub jid: Jid, + /// Whether the number is registered on WhatsApp. + pub is_registered: bool, +} + +/// Contact information from usync. +#[derive(Debug, Clone)] +pub struct ContactInfo { + /// The JID of the contact. + pub jid: Jid, + /// The LID (Linked ID) if available. + pub lid: Option, + /// Whether the number is registered on WhatsApp. + pub is_registered: bool, + /// Whether this is a business account. + pub is_business: bool, + /// Status message if available. + pub status: Option, + /// Picture ID if available. + pub picture_id: Option, +} + +/// User information from usync. +#[derive(Debug, Clone)] +pub struct UserInfo { + /// The JID of the user. + pub jid: Jid, + /// The LID (Linked ID) if available. + pub lid: Option, + /// Status message if available. + pub status: Option, + /// Picture ID if available. + pub picture_id: Option, + /// Whether this is a business account. + pub is_business: bool, +} + +/// Check if phone numbers are registered on WhatsApp. +#[derive(Debug, Clone)] +pub struct IsOnWhatsAppSpec { + /// Phone numbers to check. + pub phones: Vec, + /// Session ID for the request. + pub sid: String, +} + +impl IsOnWhatsAppSpec { + /// Create a new spec with the given phone numbers and session ID. + pub fn new(phones: Vec, sid: impl Into) -> Self { + Self { + phones, + sid: sid.into(), + } + } +} + +impl IqSpec for IsOnWhatsAppSpec { + type Response = Vec; + + fn build_iq(&self) -> InfoQuery<'static> { + let query_node = NodeBuilder::new("query") + .children(vec![NodeBuilder::new("contact").build()]) + .build(); + + let user_nodes: Vec = self + .phones + .iter() + .map(|phone| { + let phone_content = if phone.starts_with('+') { + phone.clone() + } else { + format!("+{}", phone) + }; + NodeBuilder::new("user") + .children(vec![NodeBuilder::new("contact") + .string_content(phone_content) + .build()]) + .build() + }) + .collect(); + + let list_node = NodeBuilder::new("list").children(user_nodes).build(); + + let usync_node = NodeBuilder::new("usync") + .attr("sid", self.sid.as_str()) + .attr("mode", UsyncMode::Query.as_str()) + .attr("last", "true") + .attr("index", "0") + .attr("context", UsyncContext::Interactive.as_str()) + .children(vec![query_node, list_node]) + .build(); + + InfoQuery::get( + "usync", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![usync_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let usync = response + .get_optional_child("usync") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let list = usync + .get_optional_child("list") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let mut results = Vec::new(); + + for user_node in list.get_children_by_tag("user") { + let jid_str = user_node.attrs().optional_string("jid"); + + if let Some(jid_str) = jid_str + && let Ok(jid) = jid_str.parse::() + { + let contact_node = user_node.get_optional_child("contact"); + let is_registered = contact_node + .map(|c| c.attrs().optional_string("type") == Some("in")) + .unwrap_or(false); + + results.push(IsOnWhatsAppResult { jid, is_registered }); + } + } + + Ok(results) + } +} + +/// Get contact information for phone numbers. +#[derive(Debug, Clone)] +pub struct ContactInfoSpec { + /// Phone numbers to get info for. + pub phones: Vec, + /// Session ID for the request. + pub sid: String, +} + +impl ContactInfoSpec { + /// Create a new spec with the given phone numbers and session ID. + pub fn new(phones: Vec, sid: impl Into) -> Self { + Self { + phones, + sid: sid.into(), + } + } +} + +impl IqSpec for ContactInfoSpec { + type Response = Vec; + + fn build_iq(&self) -> InfoQuery<'static> { + let query_node = NodeBuilder::new("query") + .children(vec![ + NodeBuilder::new("contact").build(), + NodeBuilder::new("lid").build(), + NodeBuilder::new("status").build(), + NodeBuilder::new("picture").build(), + NodeBuilder::new("business").build(), + ]) + .build(); + + let user_nodes: Vec = self + .phones + .iter() + .map(|phone| { + let phone_content = if phone.starts_with('+') { + phone.clone() + } else { + format!("+{}", phone) + }; + NodeBuilder::new("user") + .children(vec![NodeBuilder::new("contact") + .string_content(phone_content) + .build()]) + .build() + }) + .collect(); + + let list_node = NodeBuilder::new("list").children(user_nodes).build(); + + let usync_node = NodeBuilder::new("usync") + .attr("sid", self.sid.as_str()) + .attr("mode", UsyncMode::Query.as_str()) + .attr("last", "true") + .attr("index", "0") + .attr("context", UsyncContext::Interactive.as_str()) + .children(vec![query_node, list_node]) + .build(); + + InfoQuery::get( + "usync", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![usync_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let usync = response + .get_optional_child("usync") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let list = usync + .get_optional_child("list") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let mut results = Vec::new(); + + for user_node in list.get_children_by_tag("user") { + let jid_str = user_node.attrs().optional_string("jid"); + + if let Some(jid_str) = jid_str + && let Ok(jid) = jid_str.parse::() + { + let contact_node = user_node.get_optional_child("contact"); + let is_registered = contact_node + .map(|c| c.attrs().optional_string("type") == Some("in")) + .unwrap_or(false); + + let lid = user_node.get_optional_child("lid").and_then(|lid_node| { + lid_node + .attrs() + .optional_string("val") + .and_then(|val| val.parse::().ok()) + }); + + let status = user_node + .get_optional_child("status") + .and_then(|status_node| { + if status_node.get_optional_child("error").is_some() { + return None; + } + match &status_node.content { + Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } + }); + + let picture_id = user_node + .get_optional_child("picture") + .and_then(|pic_node| { + if pic_node.get_optional_child("error").is_some() { + return None; + } + pic_node.attrs().optional_u64("id") + }); + + let is_business = user_node.get_optional_child("business").is_some(); + + results.push(ContactInfo { + jid, + lid, + is_registered, + is_business, + status, + picture_id, + }); + } + } + + Ok(results) + } +} + +/// Get user information by JID. +#[derive(Debug, Clone)] +pub struct UserInfoSpec { + /// JIDs to get info for. + pub jids: Vec, + /// Session ID for the request. + pub sid: String, +} + +impl UserInfoSpec { + /// Create a new spec with the given JIDs and session ID. + pub fn new(jids: Vec, sid: impl Into) -> Self { + Self { + jids, + sid: sid.into(), + } + } +} + +impl IqSpec for UserInfoSpec { + type Response = HashMap; + + fn build_iq(&self) -> InfoQuery<'static> { + let query_node = NodeBuilder::new("query") + .children(vec![ + NodeBuilder::new("business") + .children(vec![NodeBuilder::new("verified_name").build()]) + .build(), + NodeBuilder::new("status").build(), + NodeBuilder::new("picture").build(), + NodeBuilder::new("devices").attr("version", "2").build(), + NodeBuilder::new("lid").build(), + ]) + .build(); + + let user_nodes: Vec = self + .jids + .iter() + .map(|jid| { + NodeBuilder::new("user") + .attr("jid", jid.to_non_ad().to_string()) + .build() + }) + .collect(); + + let list_node = NodeBuilder::new("list").children(user_nodes).build(); + + let usync_node = NodeBuilder::new("usync") + .attr("sid", self.sid.as_str()) + .attr("mode", UsyncMode::Full.as_str()) + .attr("last", "true") + .attr("index", "0") + .attr("context", UsyncContext::Background.as_str()) + .children(vec![query_node, list_node]) + .build(); + + InfoQuery::get( + "usync", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![usync_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let usync = response + .get_optional_child("usync") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let list = usync + .get_optional_child("list") + .ok_or_else(|| anyhow!("Response missing node"))?; + + let mut results = HashMap::new(); + + for user_node in list.get_children_by_tag("user") { + let jid_str = user_node.attrs().optional_string("jid"); + + if let Some(jid_str) = jid_str + && let Ok(jid) = jid_str.parse::() + { + let lid = user_node.get_optional_child("lid").and_then(|lid_node| { + lid_node + .attrs() + .optional_string("val") + .and_then(|val| val.parse::().ok()) + }); + + let status = user_node + .get_optional_child("status") + .and_then(|status_node| { + if status_node.get_optional_child("error").is_some() { + return None; + } + match &status_node.content { + Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } + }); + + let picture_id = user_node + .get_optional_child("picture") + .and_then(|pic_node| { + if pic_node.get_optional_child("error").is_some() { + return None; + } + pic_node + .attrs() + .optional_string("id") + .map(|s| s.to_string()) + }); + + let is_business = user_node.get_optional_child("business").is_some(); + + results.insert( + jid.clone(), + UserInfo { + jid, + lid, + status, + picture_id, + is_business, + }, + ); + } + } + + Ok(results) + } +} + +// Re-export types from wacore::usync for convenience +pub use crate::usync::{UserDeviceList, UsyncLidMapping}; + +/// Response from device list query. +/// +/// Contains both device lists and any LID mappings that were returned. +#[derive(Debug, Clone)] +pub struct DeviceListResponse { + /// Device lists for each user. + pub device_lists: Vec, + /// LID mappings learned from the response (if any). + pub lid_mappings: Vec, +} + +/// Get device list for JIDs. +/// +/// ## Wire Format +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// ``` +#[derive(Debug, Clone)] +pub struct DeviceListSpec { + /// JIDs to get device lists for. + pub jids: Vec, + /// Session ID for the request. + pub sid: String, +} + +impl DeviceListSpec { + /// Create a new spec with the given JIDs and session ID. + pub fn new(jids: Vec, sid: impl Into) -> Self { + Self { + jids, + sid: sid.into(), + } + } +} + +impl IqSpec for DeviceListSpec { + type Response = DeviceListResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let query_node = NodeBuilder::new("query") + .children(vec![NodeBuilder::new("devices") + .attr("version", "2") + .build()]) + .build(); + + let user_nodes: Vec = self + .jids + .iter() + .map(|jid| { + NodeBuilder::new("user") + .attr("jid", jid.to_non_ad().to_string()) + .build() + }) + .collect(); + + let list_node = NodeBuilder::new("list").children(user_nodes).build(); + + let usync_node = NodeBuilder::new("usync") + .attr("sid", self.sid.as_str()) + .attr("mode", UsyncMode::Query.as_str()) + .attr("last", "true") + .attr("index", "0") + .attr("context", UsyncContext::Message.as_str()) + .children(vec![query_node, list_node]) + .build(); + + InfoQuery::get( + "usync", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![usync_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let list_node = response + .get_optional_child_by_tag(&["usync", "list"]) + .ok_or_else(|| anyhow!(" or not found in usync response"))?; + + let mut device_lists = Vec::new(); + let mut lid_mappings = Vec::new(); + + for user_node in list_node.get_children_by_tag("user") { + let user_jid = user_node + .attrs() + .optional_jid("jid") + .ok_or_else(|| anyhow!("user node missing required 'jid' attribute"))?; + + // Extract LID mapping if present + if user_jid.server == wacore_binary::jid::DEFAULT_USER_SERVER + && let Some(lid_node) = user_node.get_optional_child("lid") + { + let lid_val = lid_node.attrs().optional_string("val").unwrap_or_default(); + if !lid_val.is_empty() + && let Ok(lid_jid) = lid_val.parse::() + && lid_jid.server == wacore_binary::jid::HIDDEN_USER_SERVER + { + lid_mappings.push(UsyncLidMapping { + phone_number: user_jid.user.clone(), + lid: lid_jid.user.clone(), + }); + } + } + + // Extract device list + let device_list_node = user_node + .get_optional_child_by_tag(&["devices", "device-list"]) + .ok_or_else(|| anyhow!(" not found for user {user_jid}"))?; + + // Extract phash from device-list node attributes + let phash = device_list_node + .attrs() + .optional_string("hash") + .map(|s| s.to_string()); + + let mut devices = Vec::new(); + for device_node in device_list_node.get_children_by_tag("device") { + let device_id_str = device_node + .attrs() + .optional_string("id") + .ok_or_else(|| anyhow!("device node missing 'id' attribute for user {user_jid}"))?; + let device_id: u16 = device_id_str + .parse() + .map_err(|e| anyhow!("invalid device id '{}' for user {}: {}", device_id_str, user_jid, e))?; + + let mut device_jid = user_jid.clone(); + device_jid.device = device_id; + devices.push(device_jid); + } + + device_lists.push(UserDeviceList { + user: user_jid.to_non_ad(), + devices, + phash, + }); + } + + Ok(DeviceListResponse { + device_lists, + lid_mappings, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_usync_mode() { + assert_eq!(UsyncMode::Query.as_str(), "query"); + assert_eq!(UsyncMode::Full.as_str(), "full"); + } + + #[test] + fn test_usync_context() { + assert_eq!(UsyncContext::Interactive.as_str(), "interactive"); + assert_eq!(UsyncContext::Background.as_str(), "background"); + assert_eq!(UsyncContext::Message.as_str(), "message"); + } + + #[test] + fn test_is_on_whatsapp_spec_build_iq() { + let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "usync"); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + let usync = &nodes[0]; + assert_eq!(usync.tag, "usync"); + assert_eq!(usync.attrs.get("sid").map(|s| s.as_str()), Some("test-sid")); + assert_eq!(usync.attrs.get("mode").map(|s| s.as_str()), Some("query")); + assert_eq!( + usync.attrs.get("context").map(|s| s.as_str()), + Some("interactive") + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_is_on_whatsapp_spec_parse_response() { + let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([NodeBuilder::new("contact").attr("type", "in").build()]) + .build()]) + .build()]) + .build()]) + .build(); + + let results = spec.parse_response(&response).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].jid.user, "1234567890"); + assert!(results[0].is_registered); + } + + #[test] + fn test_is_on_whatsapp_spec_parse_not_registered() { + let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([NodeBuilder::new("contact").attr("type", "out").build()]) + .build()]) + .build()]) + .build()]) + .build(); + + let results = spec.parse_response(&response).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].is_registered); + } + + #[test] + fn test_contact_info_spec_build_iq() { + let spec = ContactInfoSpec::new(vec!["1234567890".to_string()], "test-sid"); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "usync"); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let usync = &nodes[0]; + let query = usync.get_optional_child("query").unwrap(); + // Should have contact, lid, status, picture, business query fields + assert!(query.get_optional_child("contact").is_some()); + assert!(query.get_optional_child("lid").is_some()); + assert!(query.get_optional_child("status").is_some()); + assert!(query.get_optional_child("picture").is_some()); + assert!(query.get_optional_child("business").is_some()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_contact_info_spec_parse_response() { + let spec = ContactInfoSpec::new(vec!["1234567890".to_string()], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([ + NodeBuilder::new("contact").attr("type", "in").build(), + NodeBuilder::new("lid").attr("val", "100000001@lid").build(), + NodeBuilder::new("status") + .string_content("Hello World") + .build(), + NodeBuilder::new("picture").attr("id", "123456789").build(), + NodeBuilder::new("business").build(), + ]) + .build()]) + .build()]) + .build()]) + .build(); + + let results = spec.parse_response(&response).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].jid.user, "1234567890"); + assert!(results[0].is_registered); + assert!(results[0].is_business); + assert_eq!(results[0].status, Some("Hello World".to_string())); + assert_eq!(results[0].picture_id, Some(123456789)); + assert!(results[0].lid.is_some()); + } + + #[test] + fn test_user_info_spec_build_iq() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = UserInfoSpec::new(vec![jid], "test-sid"); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "usync"); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let usync = &nodes[0]; + assert_eq!(usync.attrs.get("mode").map(|s| s.as_str()), Some("full")); + assert_eq!( + usync.attrs.get("context").map(|s| s.as_str()), + Some("background") + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_user_info_spec_parse_response() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = UserInfoSpec::new(vec![jid.clone()], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([ + NodeBuilder::new("lid").attr("val", "100000001@lid").build(), + NodeBuilder::new("status") + .string_content("Hello World") + .build(), + NodeBuilder::new("picture").attr("id", "123456789").build(), + NodeBuilder::new("business").build(), + ]) + .build()]) + .build()]) + .build()]) + .build(); + + let results = spec.parse_response(&response).unwrap(); + assert_eq!(results.len(), 1); + let info = results.get(&jid).unwrap(); + assert_eq!(info.jid.user, "1234567890"); + assert!(info.is_business); + assert_eq!(info.status, Some("Hello World".to_string())); + assert_eq!(info.picture_id, Some("123456789".to_string())); + assert!(info.lid.is_some()); + } + + #[test] + fn test_phone_number_formatting() { + // Without plus + let spec1 = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "sid"); + let iq1 = spec1.build_iq(); + + // With plus + let spec2 = IsOnWhatsAppSpec::new(vec!["+1234567890".to_string()], "sid"); + let iq2 = spec2.build_iq(); + + // Both should produce the same formatted phone number with + + if let (Some(NodeContent::Nodes(n1)), Some(NodeContent::Nodes(n2))) = + (&iq1.content, &iq2.content) + { + let list1 = n1[0].get_optional_child("list").unwrap(); + let list2 = n2[0].get_optional_child("list").unwrap(); + let users1 = list1.get_children_by_tag("user"); + let users2 = list2.get_children_by_tag("user"); + let user1 = users1.first().unwrap(); + let user2 = users2.first().unwrap(); + let contact1 = user1.get_optional_child("contact").unwrap(); + let contact2 = user2.get_optional_child("contact").unwrap(); + + match (&contact1.content, &contact2.content) { + (Some(NodeContent::String(s1)), Some(NodeContent::String(s2))) => { + assert_eq!(s1, "+1234567890"); + assert_eq!(s2, "+1234567890"); + } + _ => panic!("Expected string content"), + } + } + } + + #[test] + fn test_device_list_spec_build_iq() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = DeviceListSpec::new(vec![jid], "test-sid"); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "usync"); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let usync = &nodes[0]; + assert_eq!(usync.attrs.get("sid").map(|s| s.as_str()), Some("test-sid")); + assert_eq!(usync.attrs.get("mode").map(|s| s.as_str()), Some("query")); + assert_eq!( + usync.attrs.get("context").map(|s| s.as_str()), + Some("message") + ); + + let query = usync.get_optional_child("query").unwrap(); + let devices = query.get_optional_child("devices").unwrap(); + assert_eq!(devices.attrs.get("version").map(|s| s.as_str()), Some("2")); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_device_list_spec_parse_response() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = DeviceListSpec::new(vec![jid], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([NodeBuilder::new("devices") + .children([NodeBuilder::new("device-list") + .attr("hash", "2:abcdef123456") + .children([ + NodeBuilder::new("device").attr("id", "0").build(), + NodeBuilder::new("device").attr("id", "1").build(), + NodeBuilder::new("device").attr("id", "5").build(), + ]) + .build()]) + .build()]) + .build()]) + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.device_lists.len(), 1); + assert_eq!(result.device_lists[0].user.user, "1234567890"); + assert_eq!(result.device_lists[0].devices.len(), 3); + assert_eq!(result.device_lists[0].devices[0].device, 0); + assert_eq!(result.device_lists[0].devices[1].device, 1); + assert_eq!(result.device_lists[0].devices[2].device, 5); + assert_eq!( + result.device_lists[0].phash, + Some("2:abcdef123456".to_string()) + ); + assert!(result.lid_mappings.is_empty()); + } + + #[test] + fn test_device_list_spec_parse_response_multiple_users() { + let jid1: Jid = "1111111111@s.whatsapp.net".parse().unwrap(); + let jid2: Jid = "2222222222@s.whatsapp.net".parse().unwrap(); + let spec = DeviceListSpec::new(vec![jid1, jid2], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([ + NodeBuilder::new("user") + .attr("jid", "1111111111@s.whatsapp.net") + .children([NodeBuilder::new("devices") + .children([NodeBuilder::new("device-list") + .attr("hash", "2:hash1") + .children([NodeBuilder::new("device").attr("id", "0").build()]) + .build()]) + .build()]) + .build(), + NodeBuilder::new("user") + .attr("jid", "2222222222@s.whatsapp.net") + .children([NodeBuilder::new("devices") + .children([NodeBuilder::new("device-list") + .attr("hash", "2:hash2") + .children([ + NodeBuilder::new("device").attr("id", "0").build(), + NodeBuilder::new("device").attr("id", "1").build(), + ]) + .build()]) + .build()]) + .build(), + ]) + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.device_lists.len(), 2); + assert_eq!(result.device_lists[0].user.user, "1111111111"); + assert_eq!(result.device_lists[0].devices.len(), 1); + assert_eq!(result.device_lists[0].phash, Some("2:hash1".to_string())); + assert_eq!(result.device_lists[1].user.user, "2222222222"); + assert_eq!(result.device_lists[1].devices.len(), 2); + assert_eq!(result.device_lists[1].phash, Some("2:hash2".to_string())); + } + + #[test] + fn test_device_list_spec_parse_response_with_lid() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = DeviceListSpec::new(vec![jid], "test-sid"); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("usync") + .children([NodeBuilder::new("list") + .children([NodeBuilder::new("user") + .attr("jid", "1234567890@s.whatsapp.net") + .children([ + NodeBuilder::new("lid") + .attr("val", "100000012345678@lid") + .build(), + NodeBuilder::new("devices") + .children([NodeBuilder::new("device-list") + .attr("hash", "2:abcdef") + .children([ + NodeBuilder::new("device").attr("id", "0").build() + ]) + .build()]) + .build(), + ]) + .build()]) + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.device_lists.len(), 1); + assert_eq!(result.lid_mappings.len(), 1); + assert_eq!(result.lid_mappings[0].phone_number, "1234567890"); + assert_eq!(result.lid_mappings[0].lid, "100000012345678"); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 623325cf4..b74e28f89 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -3,8 +3,14 @@ pub use aes_gcm; pub use wacore_appstate as appstate; pub use wacore_noise as noise; + +// Re-export derive macros +pub use wacore_derive::{EmptyNode, ProtocolNode, StringEnum}; + pub mod client; pub mod download; +pub mod iq; +pub mod protocol; pub use wacore_noise::framing; pub mod handshake; pub mod history_sync; diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index b2bc05042..03a6e758a 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -20,6 +20,7 @@ //! - Bundle encryption: AES-256-GCM after HKDF key derivation use crate::libsignal::protocol::{KeyPair, PublicKey}; +use crate::StringEnum; use aes::cipher::{KeyIvInit, StreamCipher}; use aes_gcm::Aes256Gcm; use aes_gcm::aead::{Aead, KeyInit}; @@ -53,40 +54,32 @@ const PAIR_CODE_VALIDITY_SECS: u64 = 180; /// Platform identifiers for companion devices. /// These match the DeviceProps.PlatformType protobuf enum. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] #[repr(u8)] pub enum PlatformId { + #[str = "0"] Unknown = 0, - #[default] + #[string_default] + #[str = "1"] Chrome = 1, + #[str = "2"] Firefox = 2, + #[str = "3"] InternetExplorer = 3, + #[str = "4"] Opera = 4, + #[str = "5"] Safari = 5, + #[str = "6"] Edge = 6, + #[str = "7"] Electron = 7, + #[str = "8"] Uwp = 8, + #[str = "9"] OtherWebClient = 9, } -impl PlatformId { - /// Returns the platform ID as a string (for XML content). - pub fn as_str(&self) -> &'static str { - match self { - Self::Unknown => "0", - Self::Chrome => "1", - Self::Firefox => "2", - Self::InternetExplorer => "3", - Self::Opera => "4", - Self::Safari => "5", - Self::Edge => "6", - Self::Electron => "7", - Self::Uwp => "8", - Self::OtherWebClient => "9", - } - } -} - /// Options for pair code authentication. #[derive(Debug, Clone)] pub struct PairCodeOptions { @@ -604,11 +597,13 @@ mod tests { } #[test] - fn test_platform_id_values() { - // Platform IDs match DeviceProps.PlatformType protobuf enum + fn test_platform_id_string_enum() { + // StringEnum derive works correctly + assert_eq!(PlatformId::Chrome.as_str(), "1"); + assert_eq!(PlatformId::Firefox.to_string(), "2"); + assert_eq!(PlatformId::default(), PlatformId::Chrome); + // repr(u8) values match DeviceProps.PlatformType protobuf enum assert_eq!(PlatformId::Chrome as u8, 1); - assert_eq!(PlatformId::Firefox as u8, 2); - assert_eq!(PlatformId::Edge as u8, 6); } #[test] @@ -686,25 +681,6 @@ mod tests { assert_ne!(key1, key2); } - #[test] - fn test_platform_id_as_str() { - assert_eq!(PlatformId::Unknown.as_str(), "0"); - assert_eq!(PlatformId::Chrome.as_str(), "1"); - assert_eq!(PlatformId::Firefox.as_str(), "2"); - assert_eq!(PlatformId::InternetExplorer.as_str(), "3"); - assert_eq!(PlatformId::Opera.as_str(), "4"); - assert_eq!(PlatformId::Safari.as_str(), "5"); - assert_eq!(PlatformId::Edge.as_str(), "6"); - assert_eq!(PlatformId::Electron.as_str(), "7"); - assert_eq!(PlatformId::Uwp.as_str(), "8"); - assert_eq!(PlatformId::OtherWebClient.as_str(), "9"); - } - - #[test] - fn test_platform_id_default() { - let default = PlatformId::default(); - assert_eq!(default, PlatformId::Chrome); - } #[test] fn test_pair_code_options_default() { diff --git a/wacore/src/protocol.rs b/wacore/src/protocol.rs new file mode 100644 index 000000000..8168e1671 --- /dev/null +++ b/wacore/src/protocol.rs @@ -0,0 +1,140 @@ +use anyhow::Result; +use wacore_binary::node::Node; + +/// Represents a type that maps to a WhatsApp Protocol node. +pub trait ProtocolNode: Sized { + /// The XML tag name (e.g., "create", "iq", "participant"). + fn tag(&self) -> &'static str; + + /// Convert the struct into a protocol `Node`. + fn into_node(self) -> Node; + + /// Parse a protocol `Node` into the struct. + fn try_from_node(node: &Node) -> Result; +} + +/// Macro for defining simple protocol nodes with only attributes (no children). +/// +/// This macro generates a struct with the specified fields as attributes, +/// and implements the `ProtocolNode` trait for it. +/// +/// # Example +/// +/// ```ignore +/// define_simple_node! { +/// /// A query request node. +/// /// Wire format: `` +/// pub struct QueryRequest("query") { +/// /// The request type attribute. +/// #[attr("request")] +/// pub request_type: String = "interactive", +/// } +/// } +/// ``` +/// +/// This generates: +/// - A struct `QueryRequest` with field `request_type` +/// - `ProtocolNode` implementation with tag "query" +/// - `into_node()` that creates `` +/// - `try_from_node()` that parses the node +#[macro_export] +macro_rules! define_simple_node { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($tag:literal) { + $( + $(#[$field_meta:meta])* + #[attr($attr_name:literal)] + $field_vis:vis $field:ident : $field_type:ty $(= $default:expr)? + ),* $(,)? + } + ) => { + $(#[$meta])* + #[derive(Debug, Clone)] + $vis struct $name { + $( + $(#[$field_meta])* + $field_vis $field: $field_type, + )* + } + + impl Default for $name { + fn default() -> Self { + Self { + $( + $field: $crate::define_simple_node!(@default $($default)?), + )* + } + } + } + + impl $crate::protocol::ProtocolNode for $name { + fn tag(&self) -> &'static str { + $tag + } + + fn into_node(self) -> wacore_binary::node::Node { + wacore_binary::builder::NodeBuilder::new($tag) + $(.attr($attr_name, self.$field.to_string()))* + .build() + } + + fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + if node.tag != $tag { + return Err(anyhow::anyhow!("expected <{}>, got <{}>", $tag, node.tag)); + } + Ok(Self { + $( + $field: node.attrs().optional_string($attr_name) + .map(|s| s.to_string()) + .unwrap_or_else(|| $crate::define_simple_node!(@default $($default)?)), + )* + }) + } + } + }; + + // Helper to handle default values + (@default $default:expr) => { $default.to_string() }; + (@default) => { String::new() }; +} + +/// Macro for defining an empty protocol node (tag only, no attributes or children). +/// +/// # Example +/// +/// ```ignore +/// define_empty_node!( +/// /// An empty participants request node. +/// /// Wire format: `` +/// pub struct ParticipantsRequest("participants") +/// ); +/// ``` +#[macro_export] +macro_rules! define_empty_node { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($tag:literal) + ) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + $vis struct $name; + + impl $crate::protocol::ProtocolNode for $name { + fn tag(&self) -> &'static str { + $tag + } + + fn into_node(self) -> wacore_binary::node::Node { + wacore_binary::builder::NodeBuilder::new($tag).build() + } + + fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + if node.tag != $tag { + return Err(anyhow::anyhow!("expected <{}>, got <{}>", $tag, node.tag)); + } + Ok(Self) + } + } + }; +} diff --git a/wacore/src/request.rs b/wacore/src/request.rs index 71b7e2c02..8b54dc1c4 100644 --- a/wacore/src/request.rs +++ b/wacore/src/request.rs @@ -1,3 +1,4 @@ +use crate::StringEnum; use rand::RngCore; use sha2::{Digest, Sha256}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -6,21 +7,15 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{self, Jid, JidExt}; use wacore_binary::node::{Node, NodeContent}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// IQ request type for WhatsApp protocol queries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum InfoQueryType { + #[str = "set"] Set, + #[str = "get"] Get, } -impl InfoQueryType { - pub fn as_str(&self) -> &'static str { - match self { - InfoQueryType::Set => "set", - InfoQueryType::Get => "get", - } - } -} - #[derive(Debug, Clone)] pub struct InfoQuery<'a> { pub namespace: &'a str, diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 3151779d7..e463873a3 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -3,12 +3,41 @@ use serde::Serialize; use wacore_binary::jid::{Jid, JidExt, MessageId, MessageServerId}; use waproto::whatsapp as wa; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +/// Addressing mode for a group (phone number vs LID). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] pub enum AddressingMode { + #[default] Pn, Lid, } +impl AddressingMode { + pub fn as_str(&self) -> &'static str { + match self { + AddressingMode::Pn => "pn", + AddressingMode::Lid => "lid", + } + } +} + +impl std::fmt::Display for AddressingMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TryFrom<&str> for AddressingMode { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "lid" => Ok(AddressingMode::Lid), + "pn" | "" => Ok(AddressingMode::Pn), + _ => Err(anyhow::anyhow!("unknown addressing_mode: {value}")), + } + } +} + #[derive(Debug, Clone, Default, Serialize)] pub struct MessageSource { pub chat: Jid, diff --git a/wacore/src/types/spam_report.rs b/wacore/src/types/spam_report.rs index 38057ad82..e397ce36e 100644 --- a/wacore/src/types/spam_report.rs +++ b/wacore/src/types/spam_report.rs @@ -1,36 +1,31 @@ +//! Spam report types and node building. + +use crate::StringEnum; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::Jid; use wacore_binary::node::Node; /// The type of spam flow indicating the source of the report. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum SpamFlow { /// Report triggered from group spam banner + #[str = "GroupSpamBannerReport"] GroupSpamBannerReport, /// Report triggered from group info screen + #[str = "GroupInfoReport"] GroupInfoReport, /// Report triggered from message context menu - #[default] + #[string_default] + #[str = "MessageMenu"] MessageMenu, /// Report triggered from contact info screen + #[str = "ContactInfo"] ContactInfo, /// Report triggered from status view + #[str = "StatusReport"] StatusReport, } -impl SpamFlow { - /// Returns the string representation of the spam flow. - pub fn as_str(&self) -> &'static str { - match self { - SpamFlow::GroupSpamBannerReport => "GroupSpamBannerReport", - SpamFlow::GroupInfoReport => "GroupInfoReport", - SpamFlow::MessageMenu => "MessageMenu", - SpamFlow::ContactInfo => "ContactInfo", - SpamFlow::StatusReport => "StatusReport", - } - } -} - /// A request to report a message as spam. #[derive(Debug, Clone, Default)] pub struct SpamReportRequest { @@ -133,13 +128,10 @@ mod tests { use super::*; #[test] - fn test_spam_flow_as_str() { + fn test_spam_flow_string_enum() { assert_eq!(SpamFlow::MessageMenu.as_str(), "MessageMenu"); - assert_eq!( - SpamFlow::GroupSpamBannerReport.as_str(), - "GroupSpamBannerReport" - ); - assert_eq!(SpamFlow::ContactInfo.as_str(), "ContactInfo"); + assert_eq!(SpamFlow::GroupSpamBannerReport.to_string(), "GroupSpamBannerReport"); + assert_eq!(SpamFlow::default(), SpamFlow::MessageMenu); } #[test] From 398a430b8bbdc7a42b07a8bdaf233c291fb74edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 09:48:07 -0300 Subject: [PATCH 02/18] chore: PR review things --- src/features/blocking.rs | 4 +- src/features/groups.rs | 6 +- wacore/derive/src/lib.rs | 71 ++++++++++++--- wacore/src/iq/blocklist.rs | 63 ++++--------- wacore/src/iq/contacts.rs | 26 ++---- wacore/src/iq/groups.rs | 40 -------- wacore/src/iq/keepalive.rs | 6 +- wacore/src/iq/mediaconn.rs | 32 +++---- wacore/src/iq/mex.rs | 30 ++---- wacore/src/iq/prekeys.rs | 24 ++--- wacore/src/iq/spam_report.rs | 8 +- wacore/src/iq/usync.rs | 131 +++++++++++---------------- wacore/src/pair_code.rs | 3 +- wacore/src/types/message.rs | 1 + wacore/src/types/spam_report.rs | 5 +- wacore/tests/noise_handshake_test.rs | 4 +- 16 files changed, 184 insertions(+), 270 deletions(-) diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 42cd1c0ed..79582283d 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -32,7 +32,9 @@ impl<'a> Blocking<'a> { /// Unblock a contact. pub async fn unblock(&self, jid: &Jid) -> Result<(), IqError> { debug!(target: "Blocking", "Unblocking contact: {}", jid); - self.client.execute(UpdateBlocklistSpec::unblock(jid)).await?; + self.client + .execute(UpdateBlocklistSpec::unblock(jid)) + .await?; debug!(target: "Blocking", "Successfully unblocked contact: {}", jid); Ok(()) } diff --git a/src/features/groups.rs b/src/features/groups.rs index 318978983..e7504c549 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -65,7 +65,11 @@ impl<'a> Groups<'a> { group .participants .iter() - .filter_map(|p| p.phone_number.as_ref().map(|pn| (p.jid.user.clone(), pn.clone()))) + .filter_map(|p| { + p.phone_number + .as_ref() + .map(|pn| (p.jid.user.clone(), pn.clone())) + }) .collect() } else { HashMap::new() diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 226535061..3e7998c3e 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -56,7 +56,17 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { let name = &input.ident; // Extract tag from #[protocol(tag = "...")] - let tag = extract_tag(&input.attrs).expect("ProtocolNode requires #[protocol(tag = \"...\")]"); + let tag = match extract_tag(&input.attrs) { + Some(tag) => tag, + None => { + return syn::Error::new_spanned( + &input.ident, + "ProtocolNode requires #[protocol(tag = \"...\")]", + ) + .to_compile_error() + .into(); + } + }; // Get fields for struct let fields = match &input.data { @@ -66,9 +76,23 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { // Unit struct - no fields return generate_empty_impl(name, &tag).into(); } - _ => panic!("ProtocolNode only supports named fields or unit structs"), + _ => { + return syn::Error::new_spanned( + &input.ident, + "ProtocolNode only supports named fields or unit structs", + ) + .to_compile_error() + .into(); + } }, - _ => panic!("ProtocolNode can only be derived for structs"), + _ => { + return syn::Error::new_spanned( + &input.ident, + "ProtocolNode can only be derived for structs", + ) + .to_compile_error() + .into(); + } }; // Collect field info @@ -178,7 +202,17 @@ pub fn derive_empty_node(input: TokenStream) -> TokenStream { let name = &input.ident; // Extract tag from #[protocol(tag = "...")] - let tag = extract_tag(&input.attrs).expect("EmptyNode requires #[protocol(tag = \"...\")]"); + let tag = match extract_tag(&input.attrs) { + Some(tag) => tag, + None => { + return syn::Error::new_spanned( + &input.ident, + "EmptyNode requires #[protocol(tag = \"...\")]", + ) + .to_compile_error() + .into(); + } + }; generate_empty_impl(name, &tag).into() } @@ -303,7 +337,14 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { let variants = match &input.data { Data::Enum(data) => &data.variants, - _ => panic!("StringEnum can only be derived for enums"), + _ => { + return syn::Error::new_spanned( + &input.ident, + "StringEnum can only be derived for enums", + ) + .to_compile_error() + .into(); + } }; let mut variant_infos = Vec::new(); @@ -328,12 +369,20 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { } } - let str_val = str_value.unwrap_or_else(|| { - panic!( - "StringEnum variant {} requires #[str = \"...\"] attribute", - variant_ident - ) - }); + let str_val = match str_value { + Some(v) => v, + None => { + return syn::Error::new_spanned( + variant_ident, + format!( + "StringEnum variant {} requires #[str = \"...\"] attribute", + variant_ident + ), + ) + .to_compile_error() + .into(); + } + }; if is_default { default_variant = Some(variant_ident.clone()); diff --git a/wacore/src/iq/blocklist.rs b/wacore/src/iq/blocklist.rs index 741f9e904..357aac2f5 100644 --- a/wacore/src/iq/blocklist.rs +++ b/wacore/src/iq/blocklist.rs @@ -13,18 +13,8 @@ use log::warn; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::{Node, NodeContent}; - -// ============================================================================ -// Constants -// ============================================================================ - /// IQ namespace for blocklist operations. pub const BLOCKLIST_IQ_NAMESPACE: &str = "blocklist"; - -// ============================================================================ -// Enums -// ============================================================================ - /// Action to perform on a blocklist entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum BlocklistAction { @@ -33,11 +23,6 @@ pub enum BlocklistAction { #[str = "unblock"] Unblock, } - -// ============================================================================ -// Request Types -// ============================================================================ - /// Request node for updating blocklist. /// /// Wire format: `` @@ -81,20 +66,17 @@ impl ProtocolNode for BlocklistItemRequest { return Err(anyhow!("expected , got <{}>", node.tag)); } - let action_str = optional_attr(node, "action").unwrap_or("block"); + let action_str = optional_attr(node, "action").unwrap_or_else(|| { + warn!(target: "blocklist", "missing 'action' attribute, defaulting to 'block'"); + "block" + }); let action = BlocklistAction::try_from(action_str)?; - let jid_str = optional_attr(node, "jid") - .ok_or_else(|| anyhow!("missing jid attribute"))?; + let jid_str = optional_attr(node, "jid").ok_or_else(|| anyhow!("missing jid attribute"))?; let jid = jid_str.parse()?; Ok(Self { jid, action }) } } - -// ============================================================================ -// Response Types -// ============================================================================ - /// A single blocklist entry from the response. /// /// Wire format: `` @@ -122,8 +104,7 @@ impl ProtocolNode for BlocklistEntry { return Err(anyhow!("expected , got <{}>", node.tag)); } - let jid_str = optional_attr(node, "jid") - .ok_or_else(|| anyhow!("missing jid attribute"))?; + let jid_str = optional_attr(node, "jid").ok_or_else(|| anyhow!("missing jid attribute"))?; let jid = jid_str.parse()?; let timestamp = optional_u64(node, "t"); @@ -176,16 +157,7 @@ impl ProtocolNode for BlocklistResponse { Ok(Self { entries }) } } - -// ============================================================================ -// IqSpec Implementations -// ============================================================================ - -/// IQ spec for fetching the blocklist. -/// -/// Wire format: -/// - Request: `` -/// - Response: `...` +/// Fetches the blocklist. #[derive(Debug, Default, Clone, Copy)] pub struct GetBlocklistSpec; @@ -202,11 +174,7 @@ impl IqSpec for GetBlocklistSpec { } } -/// IQ spec for updating the blocklist (block/unblock). -/// -/// Wire format: -/// - Request: `` -/// - Response: Empty (just success acknowledgement) +/// Updates the blocklist (block/unblock). #[derive(Debug, Clone)] pub struct UpdateBlocklistSpec { request: BlocklistItemRequest, @@ -247,11 +215,6 @@ impl IqSpec for UpdateBlocklistSpec { Ok(()) } } - -// ============================================================================ -// Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; @@ -260,8 +223,14 @@ mod tests { fn test_blocklist_action_string_enum() { assert_eq!(BlocklistAction::Block.as_str(), "block"); assert_eq!(BlocklistAction::Unblock.as_str(), "unblock"); - assert_eq!(BlocklistAction::try_from("block").unwrap(), BlocklistAction::Block); - assert_eq!(BlocklistAction::try_from("unblock").unwrap(), BlocklistAction::Unblock); + assert_eq!( + BlocklistAction::try_from("block").unwrap(), + BlocklistAction::Block + ); + assert_eq!( + BlocklistAction::try_from("unblock").unwrap(), + BlocklistAction::Unblock + ); } #[test] diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs index 9662e37a6..2ce7cf1f2 100644 --- a/wacore/src/iq/contacts.rs +++ b/wacore/src/iq/contacts.rs @@ -30,21 +30,16 @@ use wacore_binary::node::{Node, NodeContent}; /// Profile picture information. #[derive(Debug, Clone)] pub struct ProfilePicture { - /// Picture ID. pub id: String, - /// URL to download the picture. pub url: String, - /// Direct path for the picture (optional). pub direct_path: Option, } -/// Profile picture type. +/// Profile picture type (preview thumbnail or full-size). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ProfilePictureType { - /// Preview/thumbnail image. #[default] Preview, - /// Full-size image. Full, } @@ -57,19 +52,14 @@ impl ProfilePictureType { } } -/// Profile picture IQ specification. -/// /// Fetches the profile picture URL for a given JID. #[derive(Debug, Clone)] pub struct ProfilePictureSpec { - /// JID to get the profile picture for. pub jid: Jid, - /// Whether to get preview or full image. pub picture_type: ProfilePictureType, } impl ProfilePictureSpec { - /// Create a new profile picture spec for preview image. pub fn preview(jid: &Jid) -> Self { Self { jid: jid.clone(), @@ -77,7 +67,6 @@ impl ProfilePictureSpec { } } - /// Create a new profile picture spec for full image. pub fn full(jid: &Jid) -> Self { Self { jid: jid.clone(), @@ -85,7 +74,6 @@ impl ProfilePictureSpec { } } - /// Create a new profile picture spec with custom type. pub fn new(jid: &Jid, picture_type: ProfilePictureType) -> Self { Self { jid: jid.clone(), @@ -134,7 +122,7 @@ impl IqSpec for ProfilePictureSpec { .attrs() .optional_string("id") .map(|s| s.to_string()) - .unwrap_or_default(); + .ok_or_else(|| anyhow!("Picture response missing 'id' attribute"))?; let url = picture_node .attrs() @@ -172,7 +160,10 @@ mod tests { if let Some(NodeContent::Nodes(nodes)) = &iq.content { assert_eq!(nodes[0].tag, "picture"); - assert_eq!(nodes[0].attrs.get("type").map(|s| s.as_str()), Some("preview")); + assert_eq!( + nodes[0].attrs.get("type").map(|s| s.as_str()), + Some("preview") + ); } } @@ -185,7 +176,10 @@ mod tests { let iq = spec.build_iq(); if let Some(NodeContent::Nodes(nodes)) = &iq.content { - assert_eq!(nodes[0].attrs.get("type").map(|s| s.as_str()), Some("image")); + assert_eq!( + nodes[0].attrs.get("type").map(|s| s.as_str()), + Some("image") + ); } } diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index a9f89600f..ad8e1747c 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -11,11 +11,6 @@ use wacore_binary::node::{Node, NodeContent}; // Re-export AddressingMode from types::message for convenience pub use crate::types::message::AddressingMode; - -// ============================================================================ -// Constants (from WhatsApp Web A/B props) -// ============================================================================ - /// IQ namespace for group operations. pub const GROUP_IQ_NAMESPACE: &str = "w:g2"; @@ -27,11 +22,6 @@ pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 512; /// Maximum number of participants in a group (from `group_size_limit` A/B prop). pub const GROUP_SIZE_LIMIT: usize = 257; - -// ============================================================================ -// Enums with StringEnum derive -// ============================================================================ - /// Member link mode for group invite links. #[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum MemberLinkMode { @@ -98,11 +88,6 @@ impl TryFrom> for ParticipantType { } } } - -// ============================================================================ -// Validated Newtypes -// ============================================================================ - /// A validated group subject string. /// /// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters. @@ -168,11 +153,6 @@ impl GroupDescription { self.0 } } - -// ============================================================================ -// Group Create Options (with TypedBuilder) -// ============================================================================ - /// Options for a participant when creating a group. #[derive(Debug, Clone, TypedBuilder)] #[builder(build_method(into))] @@ -365,11 +345,6 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { .children(children) .build() } - -// ============================================================================ -// Query Request/Response Types -// ============================================================================ - /// Request to query group information. #[derive(Debug, Clone, Default)] pub struct GroupQueryRequest { @@ -498,11 +473,6 @@ impl ProtocolNode for GroupInfoResponse { }) } } - -// ============================================================================ -// Participating Request/Response Types -// ============================================================================ - /// Request to get all groups the user is participating in. #[derive(Debug, Clone)] pub struct GroupParticipatingRequest { @@ -579,11 +549,6 @@ impl ProtocolNode for GroupParticipatingResponse { Ok(Self { groups }) } } - -// ============================================================================ -// IqSpec Implementations -// ============================================================================ - /// IQ specification for querying a specific group's info. #[derive(Debug, Clone)] pub struct GroupQueryIq { @@ -680,11 +645,6 @@ impl IqSpec for GroupCreateIq { } } } - -// ============================================================================ -// Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; diff --git a/wacore/src/iq/keepalive.rs b/wacore/src/iq/keepalive.rs index 933e70052..fcff9b3e4 100644 --- a/wacore/src/iq/keepalive.rs +++ b/wacore/src/iq/keepalive.rs @@ -14,15 +14,11 @@ use crate::request::InfoQuery; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::Node; -/// Keepalive ping IQ specification. -/// -/// This is a simple ping to keep the connection alive. The server responds -/// with an empty result to confirm the connection is still active. +/// Keepalive ping to keep the connection alive. #[derive(Debug, Clone, Default)] pub struct KeepaliveSpec; impl KeepaliveSpec { - /// Create a new keepalive spec. pub fn new() -> Self { Self } diff --git a/wacore/src/iq/mediaconn.rs b/wacore/src/iq/mediaconn.rs index 123fb0811..3eddcd5ad 100644 --- a/wacore/src/iq/mediaconn.rs +++ b/wacore/src/iq/mediaconn.rs @@ -26,32 +26,23 @@ use wacore_binary::node::{Node, NodeContent}; /// Media connection host information. #[derive(Debug, Clone)] pub struct MediaConnHost { - /// The hostname for media uploads/downloads. pub hostname: String, } /// Media connection response containing auth token and hosts. #[derive(Debug, Clone)] pub struct MediaConnResponse { - /// Authentication token for media operations. pub auth: String, - /// Time-to-live in seconds for this connection info. pub ttl: u64, - /// Maximum number of buckets (optional). pub max_buckets: Option, - /// List of available media hosts. pub hosts: Vec, } -/// Media connection IQ specification. -/// -/// Requests media server connection details including authentication token -/// and available hosts for uploading/downloading media. +/// Requests media server connection details (auth token and hosts). #[derive(Debug, Clone, Default)] pub struct MediaConnSpec; impl MediaConnSpec { - /// Create a new media connection spec. pub fn new() -> Self { Self } @@ -76,17 +67,22 @@ impl IqSpec for MediaConnSpec { .ok_or_else(|| anyhow!("Missing media_conn node in response"))?; let mut attrs = media_conn_node.attrs(); - let auth = attrs.string("auth"); + let auth = attrs + .optional_string("auth") + .ok_or_else(|| anyhow!("Missing 'auth' attribute in media_conn response"))? + .to_string(); let ttl = attrs.optional_u64("ttl").unwrap_or(0); let max_buckets = attrs.optional_u64("max_buckets"); - let hosts = media_conn_node - .get_children_by_tag("host") - .iter() - .map(|host_node| MediaConnHost { - hostname: host_node.attrs().string("hostname"), - }) - .collect(); + let mut hosts = Vec::new(); + for host_node in media_conn_node.get_children_by_tag("host") { + let hostname = host_node + .attrs() + .optional_string("hostname") + .ok_or_else(|| anyhow!("Missing 'hostname' attribute in host node"))? + .to_string(); + hosts.push(MediaConnHost { hostname }); + } Ok(MediaConnResponse { auth, diff --git a/wacore/src/iq/mex.rs b/wacore/src/iq/mex.rs index 67759ed9d..5f3efb7fe 100644 --- a/wacore/src/iq/mex.rs +++ b/wacore/src/iq/mex.rs @@ -28,34 +28,25 @@ use wacore_binary::node::{Node, NodeContent}; /// MEX GraphQL error extensions. #[derive(Debug, Clone, Deserialize)] pub struct MexErrorExtensions { - /// Error code from the server. pub error_code: Option, - /// Whether this is a summary/fatal error. pub is_summary: Option, - /// Whether the request can be retried. - #[serde(default)] pub is_retryable: Option, - /// Severity level of the error. pub severity: Option, } /// MEX GraphQL error. #[derive(Debug, Clone, Deserialize)] pub struct MexGraphQLError { - /// Error message. pub message: String, - /// Error extensions with additional metadata. pub extensions: Option, } impl MexGraphQLError { - /// Get the error code if available. #[inline] pub fn error_code(&self) -> Option { self.extensions.as_ref()?.error_code } - /// Check if this is a fatal error. #[inline] pub fn is_fatal(&self) -> bool { self.extensions @@ -67,32 +58,26 @@ impl MexGraphQLError { /// MEX GraphQL response. #[derive(Debug, Clone, Deserialize)] pub struct MexResponse { - /// Response data (if successful). pub data: Option, - /// List of errors (if any). pub errors: Option>, } impl MexResponse { - /// Check if the response contains data. #[inline] pub fn has_data(&self) -> bool { self.data.is_some() } - /// Check if the response contains errors. #[inline] pub fn has_errors(&self) -> bool { self.errors.as_ref().is_some_and(|e| !e.is_empty()) } - /// Find the fatal error if present. pub fn fatal_error(&self) -> Option<&MexGraphQLError> { self.errors.as_ref()?.iter().find(|e| e.is_fatal()) } } -/// Internal payload structure for MEX requests. #[derive(Serialize)] struct MexPayload<'a> { variables: &'a Value, @@ -101,14 +86,11 @@ struct MexPayload<'a> { /// MEX GraphQL query IQ specification. #[derive(Debug, Clone)] pub struct MexQuerySpec { - /// The GraphQL document ID (query_id). pub doc_id: String, - /// Variables for the GraphQL query. pub variables: Value, } impl MexQuerySpec { - /// Create a new MEX query spec. pub fn new(doc_id: impl Into, variables: Value) -> Self { Self { doc_id: doc_id.into(), @@ -124,6 +106,8 @@ impl IqSpec for MexQuerySpec { let payload = MexPayload { variables: &self.variables, }; + // Safety: MexPayload wraps &serde_json::Value, and serde_json::to_vec + // cannot fail for Value (no custom serializers or non-string map keys). let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default(); let query_node = NodeBuilder::new("query") @@ -143,13 +127,13 @@ impl IqSpec for MexQuerySpec { .get_optional_child("result") .ok_or_else(|| anyhow!("Missing node in MEX response"))?; - let result_bytes = match &result_node.content { - Some(NodeContent::Bytes(bytes)) => bytes, - _ => return Err(anyhow!("MEX result node content is not binary")), + // Handle both binary and string content from the server + let mex_response: MexResponse = match &result_node.content { + Some(NodeContent::Bytes(bytes)) => serde_json::from_slice(bytes)?, + Some(NodeContent::String(s)) => serde_json::from_str(s)?, + _ => return Err(anyhow!("MEX result node content is not binary or string")), }; - let mex_response: MexResponse = serde_json::from_slice(result_bytes)?; - // Check for fatal errors if let Some(fatal) = mex_response.fatal_error() { let code = fatal.error_code().unwrap_or(500); diff --git a/wacore/src/iq/prekeys.rs b/wacore/src/iq/prekeys.rs index 4ca08c802..2ae38ac76 100644 --- a/wacore/src/iq/prekeys.rs +++ b/wacore/src/iq/prekeys.rs @@ -51,18 +51,14 @@ pub use crate::libsignal::protocol::PreKeyBundle; /// Pre-key count response. #[derive(Debug, Clone)] pub struct PreKeyCountResponse { - /// Number of pre-keys available on the server. pub count: usize, } -/// Pre-key count IQ specification. -/// -/// Queries the server for how many pre-keys are currently stored for this device. +/// Queries the server for how many pre-keys are currently stored. #[derive(Debug, Clone, Default)] pub struct PreKeyCountSpec; impl PreKeyCountSpec { - /// Create a new pre-key count spec. pub fn new() -> Self { Self } @@ -86,34 +82,27 @@ impl IqSpec for PreKeyCountSpec { .get_optional_child("count") .ok_or_else(|| anyhow!("Missing node in response"))?; - let count_str = count_node - .attrs() - .optional_string("value") - .unwrap_or("0"); + // Server may return without value attribute when count is 0, + // or return an unparseable value. Default to 0 in these cases. + let count_str = count_node.attrs().optional_string("value").unwrap_or("0"); let count = count_str.parse::().unwrap_or(0); Ok(PreKeyCountResponse { count }) } } -/// Pre-key fetch IQ specification. -/// -/// Fetches pre-key bundles for a list of JIDs from the server. +/// Fetches pre-key bundles for a list of JIDs. #[derive(Debug, Clone)] pub struct PreKeyFetchSpec { - /// JIDs to fetch pre-keys for. pub jids: Vec, - /// Optional reason for the fetch (e.g., "retry"). pub reason: Option, } impl PreKeyFetchSpec { - /// Create a new pre-key fetch spec. pub fn new(jids: Vec) -> Self { Self { jids, reason: None } } - /// Create a new pre-key fetch spec with a reason. pub fn with_reason(jids: Vec, reason: impl Into) -> Self { Self { jids, @@ -126,8 +115,7 @@ impl IqSpec for PreKeyFetchSpec { type Response = std::collections::HashMap; fn build_iq(&self) -> InfoQuery<'static> { - let content = - PreKeyUtils::build_fetch_prekeys_request(&self.jids, self.reason.as_deref()); + let content = PreKeyUtils::build_fetch_prekeys_request(&self.jids, self.reason.as_deref()); InfoQuery::get( "encrypt", diff --git a/wacore/src/iq/spam_report.rs b/wacore/src/iq/spam_report.rs index 9c56aa030..a61851a77 100644 --- a/wacore/src/iq/spam_report.rs +++ b/wacore/src/iq/spam_report.rs @@ -24,19 +24,17 @@ use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::{Node, NodeContent}; // Re-export types for convenience -pub use crate::types::spam_report::{SpamFlow, SpamReportRequest as Request, SpamReportResult as Response}; +pub use crate::types::spam_report::{ + SpamFlow, SpamReportRequest as Request, SpamReportResult as Response, +}; -/// Spam report IQ specification. -/// /// Sends a spam report for one or more messages to WhatsApp. #[derive(Debug, Clone)] pub struct SpamReportSpec { - /// The spam report request. pub request: SpamReportRequest, } impl SpamReportSpec { - /// Create a new spam report spec. pub fn new(request: SpamReportRequest) -> Self { Self { request } } diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs index 4ec5f25d3..6e7cc44c3 100644 --- a/wacore/src/iq/usync.rs +++ b/wacore/src/iq/usync.rs @@ -45,6 +45,7 @@ use crate::iq::spec::IqSpec; use crate::request::InfoQuery; use anyhow::anyhow; +use log::warn; use std::collections::HashMap; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, SERVER_JID}; @@ -91,58 +92,68 @@ impl UsyncContext { } } +/// Build user nodes with phone number contact children. +fn build_phone_user_nodes(phones: &[String]) -> Vec { + phones + .iter() + .map(|phone| { + let phone_content = if phone.starts_with('+') { + phone.clone() + } else { + format!("+{}", phone) + }; + NodeBuilder::new("user") + .children(vec![ + NodeBuilder::new("contact") + .string_content(phone_content) + .build(), + ]) + .build() + }) + .collect() +} + /// Result of checking if a phone number is on WhatsApp. #[derive(Debug, Clone)] pub struct IsOnWhatsAppResult { - /// The JID of the user (if registered). pub jid: Jid, - /// Whether the number is registered on WhatsApp. pub is_registered: bool, } /// Contact information from usync. #[derive(Debug, Clone)] pub struct ContactInfo { - /// The JID of the contact. pub jid: Jid, - /// The LID (Linked ID) if available. pub lid: Option, - /// Whether the number is registered on WhatsApp. pub is_registered: bool, - /// Whether this is a business account. pub is_business: bool, - /// Status message if available. pub status: Option, - /// Picture ID if available. pub picture_id: Option, } /// User information from usync. +/// +/// Note: `picture_id` is `Option` here vs `Option` in `ContactInfo`. +/// The server returns picture IDs in different formats depending on the usync mode: +/// - Query mode (ContactInfo): numeric ID that fits in u64 +/// - Full mode (UserInfo): may include non-numeric prefixes, kept as String for safety #[derive(Debug, Clone)] pub struct UserInfo { - /// The JID of the user. pub jid: Jid, - /// The LID (Linked ID) if available. pub lid: Option, - /// Status message if available. pub status: Option, - /// Picture ID if available. pub picture_id: Option, - /// Whether this is a business account. pub is_business: bool, } /// Check if phone numbers are registered on WhatsApp. #[derive(Debug, Clone)] pub struct IsOnWhatsAppSpec { - /// Phone numbers to check. pub phones: Vec, - /// Session ID for the request. pub sid: String, } impl IsOnWhatsAppSpec { - /// Create a new spec with the given phone numbers and session ID. pub fn new(phones: Vec, sid: impl Into) -> Self { Self { phones, @@ -159,23 +170,7 @@ impl IqSpec for IsOnWhatsAppSpec { .children(vec![NodeBuilder::new("contact").build()]) .build(); - let user_nodes: Vec = self - .phones - .iter() - .map(|phone| { - let phone_content = if phone.starts_with('+') { - phone.clone() - } else { - format!("+{}", phone) - }; - NodeBuilder::new("user") - .children(vec![NodeBuilder::new("contact") - .string_content(phone_content) - .build()]) - .build() - }) - .collect(); - + let user_nodes = build_phone_user_nodes(&self.phones); let list_node = NodeBuilder::new("list").children(user_nodes).build(); let usync_node = NodeBuilder::new("usync") @@ -227,14 +222,11 @@ impl IqSpec for IsOnWhatsAppSpec { /// Get contact information for phone numbers. #[derive(Debug, Clone)] pub struct ContactInfoSpec { - /// Phone numbers to get info for. pub phones: Vec, - /// Session ID for the request. pub sid: String, } impl ContactInfoSpec { - /// Create a new spec with the given phone numbers and session ID. pub fn new(phones: Vec, sid: impl Into) -> Self { Self { phones, @@ -257,23 +249,7 @@ impl IqSpec for ContactInfoSpec { ]) .build(); - let user_nodes: Vec = self - .phones - .iter() - .map(|phone| { - let phone_content = if phone.starts_with('+') { - phone.clone() - } else { - format!("+{}", phone) - }; - NodeBuilder::new("user") - .children(vec![NodeBuilder::new("contact") - .string_content(phone_content) - .build()]) - .build() - }) - .collect(); - + let user_nodes = build_phone_user_nodes(&self.phones); let list_node = NodeBuilder::new("list").children(user_nodes).build(); let usync_node = NodeBuilder::new("usync") @@ -362,14 +338,11 @@ impl IqSpec for ContactInfoSpec { /// Get user information by JID. #[derive(Debug, Clone)] pub struct UserInfoSpec { - /// JIDs to get info for. pub jids: Vec, - /// Session ID for the request. pub sid: String, } impl UserInfoSpec { - /// Create a new spec with the given JIDs and session ID. pub fn new(jids: Vec, sid: impl Into) -> Self { Self { jids, @@ -492,14 +465,10 @@ impl IqSpec for UserInfoSpec { // Re-export types from wacore::usync for convenience pub use crate::usync::{UserDeviceList, UsyncLidMapping}; -/// Response from device list query. -/// -/// Contains both device lists and any LID mappings that were returned. +/// Response from device list query containing device lists and any LID mappings. #[derive(Debug, Clone)] pub struct DeviceListResponse { - /// Device lists for each user. pub device_lists: Vec, - /// LID mappings learned from the response (if any). pub lid_mappings: Vec, } @@ -537,14 +506,11 @@ pub struct DeviceListResponse { /// ``` #[derive(Debug, Clone)] pub struct DeviceListSpec { - /// JIDs to get device lists for. pub jids: Vec, - /// Session ID for the request. pub sid: String, } impl DeviceListSpec { - /// Create a new spec with the given JIDs and session ID. pub fn new(jids: Vec, sid: impl Into) -> Self { Self { jids, @@ -558,9 +524,9 @@ impl IqSpec for DeviceListSpec { fn build_iq(&self) -> InfoQuery<'static> { let query_node = NodeBuilder::new("query") - .children(vec![NodeBuilder::new("devices") - .attr("version", "2") - .build()]) + .children(vec![ + NodeBuilder::new("devices").attr("version", "2").build(), + ]) .build(); let user_nodes: Vec = self @@ -621,10 +587,16 @@ impl IqSpec for DeviceListSpec { } } - // Extract device list - let device_list_node = user_node + // Extract device list - skip user if not present + let device_list_node = match user_node .get_optional_child_by_tag(&["devices", "device-list"]) - .ok_or_else(|| anyhow!(" not found for user {user_jid}"))?; + { + Some(node) => node, + None => { + warn!(target: "usync", " not found for user {user_jid}, skipping"); + continue; + } + }; // Extract phash from device-list node attributes let phash = device_list_node @@ -634,13 +606,14 @@ impl IqSpec for DeviceListSpec { let mut devices = Vec::new(); for device_node in device_list_node.get_children_by_tag("device") { - let device_id_str = device_node - .attrs() - .optional_string("id") - .ok_or_else(|| anyhow!("device node missing 'id' attribute for user {user_jid}"))?; - let device_id: u16 = device_id_str - .parse() - .map_err(|e| anyhow!("invalid device id '{}' for user {}: {}", device_id_str, user_jid, e))?; + let Some(device_id_str) = device_node.attrs().optional_string("id") else { + warn!(target: "usync", "device node missing 'id' attribute for user {user_jid}, skipping device"); + continue; + }; + let Ok(device_id) = device_id_str.parse::() else { + warn!(target: "usync", "invalid device id '{}' for user {user_jid}, skipping device", device_id_str); + continue; + }; let mut device_jid = user_jid.clone(); device_jid.device = device_id; @@ -1015,9 +988,7 @@ mod tests { NodeBuilder::new("devices") .children([NodeBuilder::new("device-list") .attr("hash", "2:abcdef") - .children([ - NodeBuilder::new("device").attr("id", "0").build() - ]) + .children([NodeBuilder::new("device").attr("id", "0").build()]) .build()]) .build(), ]) diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 03a6e758a..74a8a0676 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -19,8 +19,8 @@ //! - Ephemeral encryption: AES-256-CTR //! - Bundle encryption: AES-256-GCM after HKDF key derivation -use crate::libsignal::protocol::{KeyPair, PublicKey}; use crate::StringEnum; +use crate::libsignal::protocol::{KeyPair, PublicKey}; use aes::cipher::{KeyIvInit, StreamCipher}; use aes_gcm::Aes256Gcm; use aes_gcm::aead::{Aead, KeyInit}; @@ -681,7 +681,6 @@ mod tests { assert_ne!(key1, key2); } - #[test] fn test_pair_code_options_default() { let options = PairCodeOptions::default(); diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index e463873a3..63dfd4356 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -5,6 +5,7 @@ use waproto::whatsapp as wa; /// Addressing mode for a group (phone number vs LID). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum AddressingMode { #[default] Pn, diff --git a/wacore/src/types/spam_report.rs b/wacore/src/types/spam_report.rs index e397ce36e..deb1a8307 100644 --- a/wacore/src/types/spam_report.rs +++ b/wacore/src/types/spam_report.rs @@ -130,7 +130,10 @@ mod tests { #[test] fn test_spam_flow_string_enum() { assert_eq!(SpamFlow::MessageMenu.as_str(), "MessageMenu"); - assert_eq!(SpamFlow::GroupSpamBannerReport.to_string(), "GroupSpamBannerReport"); + assert_eq!( + SpamFlow::GroupSpamBannerReport.to_string(), + "GroupSpamBannerReport" + ); assert_eq!(SpamFlow::default(), SpamFlow::MessageMenu); } diff --git a/wacore/tests/noise_handshake_test.rs b/wacore/tests/noise_handshake_test.rs index 10483991c..8db4c0b20 100644 --- a/wacore/tests/noise_handshake_test.rs +++ b/wacore/tests/noise_handshake_test.rs @@ -174,7 +174,7 @@ fn test_full_handshake_flow_with_go_data() { assert_eq!(*nh.hash(), hash_after_prologue, "Mismatch after prologue"); println!("Step 2: Auth Client Ephemeral"); - let _ = nh.authenticate(&client_eph_pub); + nh.authenticate(&client_eph_pub); assert_eq!( *nh.hash(), hash_after_auth_client_eph, @@ -182,7 +182,7 @@ fn test_full_handshake_flow_with_go_data() { ); println!("Step 3: Auth Server Ephemeral"); - let _ = nh.authenticate(&server_eph_pub); + nh.authenticate(&server_eph_pub); assert_eq!( *nh.hash(), hash_after_auth_server_eph, From b3f718ead35b537579008380903159a42c2b0c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 12:04:01 -0300 Subject: [PATCH 03/18] chore: deprecate .string method in flavor of required or optional one --- src/features/blocking.rs | 2 +- wacore/Cargo.toml | 2 +- wacore/binary/src/attrs.rs | 40 ++++++++++++++++++++++++++++++++++++++ wacore/binary/src/error.rs | 3 +++ wacore/derive/src/lib.rs | 4 +++- wacore/src/iq/blocklist.rs | 6 ++---- wacore/src/iq/groups.rs | 9 +++++++-- 7 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 79582283d..60f3c9ddb 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -50,7 +50,7 @@ impl<'a> Blocking<'a> { /// Check if a contact is blocked. pub async fn is_blocked(&self, jid: &Jid) -> Result { let blocklist = self.get_blocklist().await?; - Ok(blocklist.iter().any(|e| e.jid.user == jid.user)) + Ok(blocklist.iter().any(|e| &e.jid == jid)) } } diff --git a/wacore/Cargo.toml b/wacore/Cargo.toml index 93ce86134..5f6e94fc0 100644 --- a/wacore/Cargo.toml +++ b/wacore/Cargo.toml @@ -33,7 +33,7 @@ rand = { workspace = true } rand_core = { workspace = true } serde = { workspace = true } serde-big-array = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["std"] } sha2 = { workspace = true } thiserror = { workspace = true } wacore-appstate = { workspace = true } diff --git a/wacore/binary/src/attrs.rs b/wacore/binary/src/attrs.rs index ad8cca281..38772759d 100644 --- a/wacore/binary/src/attrs.rs +++ b/wacore/binary/src/attrs.rs @@ -57,6 +57,26 @@ impl<'a> AttrParserRef<'a> { self.get_raw(key, false).and_then(|v| v.as_str()) } + /// Get a required string attribute, returning an error if missing. + /// + /// Prefer this over `string()` for required attributes as it makes + /// the error explicit rather than silently defaulting to empty string. + pub fn required_string(&mut self, key: &str) -> Result<&'a str> { + self.optional_string(key) + .ok_or_else(|| BinaryError::MissingAttr(key.to_string())) + } + + /// Get string, defaulting to empty string if missing. + /// + /// # Deprecation + /// + /// This method silently defaults to an empty string when the attribute is missing. + /// Use `optional_string()` with explicit error handling or `required_string()` + /// to avoid silent failures. + #[deprecated( + since = "0.2.0", + note = "Use optional_string() with explicit handling or required_string() instead" + )] pub fn string(&mut self, key: &str) -> String { self.get_raw(key, true) .map(|v| v.to_string_cow().into_owned()) @@ -194,6 +214,26 @@ impl<'a> AttrParser<'a> { self.get_raw(key, false).map(|s| s.as_str()) } + /// Get a required string attribute, returning an error if missing. + /// + /// Prefer this over `string()` for required attributes as it makes + /// the error explicit rather than silently defaulting to empty string. + pub fn required_string(&mut self, key: &str) -> Result<&'a str> { + self.optional_string(key) + .ok_or_else(|| BinaryError::MissingAttr(key.to_string())) + } + + /// Get string, defaulting to empty string if missing. + /// + /// # Deprecation + /// + /// This method silently defaults to an empty string when the attribute is missing. + /// Use `optional_string()` with explicit error handling or `required_string()` + /// to avoid silent failures. + #[deprecated( + since = "0.2.0", + note = "Use optional_string() with explicit handling or required_string() instead" + )] pub fn string(&mut self, key: &str) -> String { self.get_raw(key, true).cloned().unwrap_or_default() } diff --git a/wacore/binary/src/error.rs b/wacore/binary/src/error.rs index 8cccd159b..c9f51b02f 100644 --- a/wacore/binary/src/error.rs +++ b/wacore/binary/src/error.rs @@ -9,6 +9,7 @@ pub enum BinaryError { InvalidNode, NonStringKey, AttrParse(String), + MissingAttr(String), InvalidUtf8(std::str::Utf8Error), Zlib(String), Jid(JidError), @@ -26,6 +27,7 @@ impl fmt::Display for BinaryError { BinaryError::InvalidNode => write!(f, "Invalid node format"), BinaryError::NonStringKey => write!(f, "Attribute key was not a string"), BinaryError::AttrParse(s) => write!(f, "Attribute parsing failed: {s}"), + BinaryError::MissingAttr(s) => write!(f, "Missing required attribute: {s}"), BinaryError::InvalidUtf8(e) => write!(f, "Data is not valid UTF-8: {e}"), BinaryError::Zlib(s) => write!(f, "Zlib decompression error: {s}"), BinaryError::Jid(e) => write!(f, "JID parsing error: {e}"), @@ -71,6 +73,7 @@ impl Clone for BinaryError { BinaryError::InvalidNode => BinaryError::InvalidNode, BinaryError::NonStringKey => BinaryError::NonStringKey, BinaryError::AttrParse(s) => BinaryError::AttrParse(s.clone()), + BinaryError::MissingAttr(s) => BinaryError::MissingAttr(s.clone()), BinaryError::InvalidUtf8(e) => BinaryError::InvalidUtf8(*e), BinaryError::Zlib(s) => BinaryError::Zlib(s.clone()), BinaryError::Jid(e) => BinaryError::Jid(JidError::InvalidFormat(e.to_string())), diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 3e7998c3e..0664fbc97 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -129,7 +129,9 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } } else { quote! { - #field_ident: node.attrs().string(#attr_name).to_string() + #field_ident: node.attrs().optional_string(#attr_name) + .ok_or_else(|| anyhow::anyhow!("missing required attribute '{}'", #attr_name))? + .to_string() } } }) diff --git a/wacore/src/iq/blocklist.rs b/wacore/src/iq/blocklist.rs index 357aac2f5..f5d2efa35 100644 --- a/wacore/src/iq/blocklist.rs +++ b/wacore/src/iq/blocklist.rs @@ -66,10 +66,8 @@ impl ProtocolNode for BlocklistItemRequest { return Err(anyhow!("expected , got <{}>", node.tag)); } - let action_str = optional_attr(node, "action").unwrap_or_else(|| { - warn!(target: "blocklist", "missing 'action' attribute, defaulting to 'block'"); - "block" - }); + let action_str = + optional_attr(node, "action").ok_or_else(|| anyhow!("missing action attribute"))?; let action = BlocklistAction::try_from(action_str)?; let jid_str = optional_attr(node, "jid").ok_or_else(|| anyhow!("missing jid attribute"))?; let jid = jid_str.parse()?; diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index ad8e1747c..51add31b3 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -303,7 +303,10 @@ pub fn build_create_group_node(options: &GroupCreateOptions) -> Node { ); } - for participant in &options.participants { + // Normalize participants to avoid sending phone_number for non-LID JIDs + let participants = normalize_participants(&options.participants); + + for participant in &participants { let mut attrs = vec![("jid", participant.jid.to_string())]; if let Some(pn) = &participant.phone_number { attrs.push(("phone_number", pn.to_string())); @@ -403,7 +406,9 @@ impl ProtocolNode for GroupParticipantResponse { .optional_jid("jid") .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?; let phone_number = node.attrs().optional_jid("phone_number"); - let participant_type = ParticipantType::try_from(node.attrs().optional_string("type"))?; + // Default to Member for unknown participant types to avoid failing the whole group parse + let participant_type = ParticipantType::try_from(node.attrs().optional_string("type")) + .unwrap_or(ParticipantType::Member); Ok(Self { jid, From f0d48ce2f0e616041ab0c89a336890aff639fc9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 12:32:59 -0300 Subject: [PATCH 04/18] chore: use optional attribute parsers and add guards --- src/client.rs | 7 +++-- src/handlers/ib.rs | 8 +++++- src/handlers/message.rs | 14 +++++----- src/handlers/notification.rs | 5 +++- src/message.rs | 31 ++++++++++++++++++++--- src/receipt.rs | 8 +++++- src/retry.rs | 6 ++++- src/spam_report.rs | 1 + src/types/enc_handler.rs | 6 ++++- wacore/appstate/src/patch_decode.rs | 5 +++- wacore/binary/benches/binary_benchmark.rs | 4 +-- wacore/src/iq/blocklist.rs | 1 + wacore/src/reporting_token.rs | 1 + wacore/src/types/spam_report.rs | 1 + wacore/src/usync.rs | 18 ++++++++++--- wacore/tests/binary_protocol_test.rs | 1 + 16 files changed, 94 insertions(+), 23 deletions(-) diff --git a/src/client.rs b/src/client.rs index c34311e55..a1d5c4dba 100644 --- a/src/client.rs +++ b/src/client.rs @@ -644,7 +644,10 @@ impl Client { && let Some(sync_node) = node.get_optional_child("sync") && let Some(collection_node) = sync_node.get_optional_child("collection") { - let name = collection_node.attrs().string("name"); + let name = collection_node + .attrs() + .optional_string("name") + .unwrap_or(""); info!(target: "Client/Recv", "Received app state sync response for '{name}' (hiding content)."); } else { info!(target: "Client/Recv","{}", DisplayableNode(&node)); @@ -1574,7 +1577,7 @@ impl Client { info!(target: "Client", "Received ping, sending pong."); let mut parser = node.attrs(); let from_jid = parser.jid("from"); - let id = parser.string("id"); + let id = parser.optional_string("id").unwrap_or("").to_string(); let pong = NodeBuilder::new("iq") .attrs([ ("to", from_jid.to_string()), diff --git a/src/handlers/ib.rs b/src/handlers/ib.rs index ed58a26ff..23616f09c 100644 --- a/src/handlers/ib.rs +++ b/src/handlers/ib.rs @@ -34,7 +34,13 @@ async fn handle_ib_impl(client: Arc, node: &Node) { match child.tag.as_str() { "dirty" => { let mut attrs = child.attrs(); - let dirty_type = attrs.string("type"); + let dirty_type = match attrs.optional_string("type") { + Some(t) => t.to_string(), + None => { + warn!(target: "Client", "Dirty notification missing 'type' attribute"); + continue; + } + }; let timestamp = attrs.optional_string("timestamp").map(|s| s.to_string()); info!( diff --git a/src/handlers/message.rs b/src/handlers/message.rs index 0cc7234c6..b45350d16 100644 --- a/src/handlers/message.rs +++ b/src/handlers/message.rs @@ -27,14 +27,16 @@ impl StanzaHandler for MessageHandler { } async fn handle(&self, client: Arc, node: Arc, _cancelled: &mut bool) -> bool { - // Extract the chat ID (from attribute) to serialize processing for this chat. + // Extract the chat ID to serialize processing for this chat. // This prevents race conditions where a later message is processed before // the PreKey message that establishes the session. - let chat_id = node.attrs().string("from"); - - if chat_id.is_empty() { - return false; - } + let chat_id = match node.attrs().optional_string("from") { + Some(id) => id.to_string(), + None => { + log::warn!("Message stanza missing required 'from' attribute"); + return false; + } + }; // Node is already Arc-wrapped - no cloning needed! // This is the key optimization: we pass the same Arc through the system. diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index f42813d92..51f097ff6 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -64,7 +64,10 @@ async fn handle_notification_impl(client: &Arc, node: &Node) { // Just acknowledge without syncing. if let Some(children) = node.children() { for collection_node in children.iter().filter(|c| c.tag == "collection") { - let name = collection_node.attrs().string("name"); + let name = collection_node + .attrs() + .optional_string("name") + .unwrap_or(""); let version = collection_node.attrs().optional_u64("version").unwrap_or(0); debug!( target: "Client/AppState", diff --git a/src/message.rs b/src/message.rs index 4b8c475c7..d6ec1bb8f 100644 --- a/src/message.rs +++ b/src/message.rs @@ -360,7 +360,10 @@ impl Client { if let Some(participants_node) = participants { let to_nodes = participants_node.get_children_by_tag("to"); for to_node in to_nodes { - let to_jid = to_node.attrs().string("jid"); + let to_jid = match to_node.attrs().optional_string("jid") { + Some(jid) => jid.to_string(), + None => continue, + }; let own_jid = self.get_pn().await; if let Some(our_jid) = own_jid @@ -381,7 +384,13 @@ impl Client { let mut group_content_enc_nodes = Vec::with_capacity(all_enc_nodes.len()); for &enc_node in &all_enc_nodes { - let enc_type = enc_node.attrs().string("type"); + let enc_type = match enc_node.attrs().optional_string("type") { + Some(t) => t.to_string(), + None => { + log::warn!("Enc node missing 'type' attribute, skipping"); + continue; + } + }; if let Some(handler) = self.custom_enc_handlers.get(&enc_type) { let handler_clone = handler.clone(); @@ -553,7 +562,13 @@ impl Client { continue; } }; - let enc_type = enc_node.attrs().string("type"); + let enc_type = match enc_node.attrs().optional_string("type") { + Some(t) => t.to_string(), + None => { + log::warn!("Enc node missing 'type' attribute (batch session)"); + continue; + } + }; let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8; let parsed_message = if enc_type == "pkmsg" { @@ -1123,9 +1138,17 @@ impl Client { .map(|s| s.to_string()) .unwrap_or_default(); + let id = match attrs.optional_string("id") { + Some(id) => id.to_string(), + None => { + log::warn!("Message missing 'id' attribute"); + String::new() + } + }; + Ok(MessageInfo { source, - id: attrs.string("id"), + id, push_name: attrs .optional_string("notify") .map(|s| s.to_string()) diff --git a/src/receipt.rs b/src/receipt.rs index eaee08f3f..19bcc9e29 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -13,7 +13,13 @@ impl Client { pub(crate) async fn handle_receipt(self: &Arc, node: Arc) { let mut attrs = node.attrs(); let from = attrs.jid("from"); - let id = attrs.string("id"); + let id = match attrs.optional_string("id") { + Some(id) => id.to_string(), + None => { + log::warn!("Receipt stanza missing required 'id' attribute"); + return; + } + }; let receipt_type_str = attrs.optional_string("type").unwrap_or("delivery"); let participant = attrs.optional_jid("participant"); diff --git a/src/retry.rs b/src/retry.rs index 256873612..405abb9f6 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -65,7 +65,11 @@ impl Client { .get_optional_child("retry") .ok_or_else(|| anyhow::anyhow!(" child missing from receipt"))?; - let message_id = retry_child.attrs().string("id"); + let message_id = retry_child + .attrs() + .optional_string("id") + .ok_or_else(|| anyhow::anyhow!(" missing 'id' attribute"))? + .to_string(); let retry_count: u8 = retry_child .attrs() .optional_string("count") diff --git a/src/spam_report.rs b/src/spam_report.rs index 71d2f9076..f8ade2be5 100644 --- a/src/spam_report.rs +++ b/src/spam_report.rs @@ -41,6 +41,7 @@ impl Client { } #[cfg(test)] +#[allow(deprecated)] mod tests { use super::*; use wacore_binary::jid::Jid; diff --git a/src/types/enc_handler.rs b/src/types/enc_handler.rs index 4ca36e15b..8e127b6e4 100644 --- a/src/types/enc_handler.rs +++ b/src/types/enc_handler.rs @@ -51,7 +51,11 @@ mod tests { enc_node: &Node, _info: &MessageInfo, ) -> Result<()> { - let enc_type = enc_node.attrs().string("type"); + let enc_type = enc_node + .attrs() + .optional_string("type") + .unwrap_or("unknown") + .to_string(); self.calls.lock().await.push(enc_type); Ok(()) } diff --git a/wacore/appstate/src/patch_decode.rs b/wacore/appstate/src/patch_decode.rs index dadf78787..e6fc78290 100644 --- a/wacore/appstate/src/patch_decode.rs +++ b/wacore/appstate/src/patch_decode.rs @@ -59,7 +59,10 @@ pub fn parse_patch_list(node: &Node) -> Result { .get_optional_child_by_tag(&["sync", "collection"]) // naive path descent .ok_or_else(|| anyhow!("missing sync/collection"))?; let mut ag = collection.attrs(); - let name_str = ag.string("name"); + let name_str = ag + .optional_string("name") + .ok_or_else(|| anyhow!("collection missing 'name' attribute"))? + .to_string(); let has_more = ag.optional_bool("has_more_patches"); ag.finish()?; // propagate attr parse errors diff --git a/wacore/binary/benches/binary_benchmark.rs b/wacore/binary/benches/binary_benchmark.rs index 970ca2ee2..40c67f3e3 100644 --- a/wacore/binary/benches/binary_benchmark.rs +++ b/wacore/binary/benches/binary_benchmark.rs @@ -123,10 +123,10 @@ fn bench_attr_parser(marshaled: Vec) { let node_ref = unmarshal_ref(&marshaled[1..]).unwrap(); let mut parser = node_ref.attr_parser(); - black_box(parser.string("xmlns")); + black_box(parser.optional_string("xmlns")); black_box(parser.optional_string("type")); black_box(parser.optional_jid("from")); - black_box(parser.bool("has_flag")); + black_box(parser.optional_bool("has_flag")); black_box(parser.optional_u64("timestamp")); black_box(parser.finish().is_ok()); } diff --git a/wacore/src/iq/blocklist.rs b/wacore/src/iq/blocklist.rs index f5d2efa35..790d3a67c 100644 --- a/wacore/src/iq/blocklist.rs +++ b/wacore/src/iq/blocklist.rs @@ -214,6 +214,7 @@ impl IqSpec for UpdateBlocklistSpec { } } #[cfg(test)] +#[allow(deprecated)] mod tests { use super::*; diff --git a/wacore/src/reporting_token.rs b/wacore/src/reporting_token.rs index 7bf706917..5e2b4ccb7 100644 --- a/wacore/src/reporting_token.rs +++ b/wacore/src/reporting_token.rs @@ -612,6 +612,7 @@ pub fn extract_message_secret(message: &wa::Message) -> Option<&[u8]> { } #[cfg(test)] +#[allow(deprecated)] mod tests { use super::*; diff --git a/wacore/src/types/spam_report.rs b/wacore/src/types/spam_report.rs index deb1a8307..e69cec2e5 100644 --- a/wacore/src/types/spam_report.rs +++ b/wacore/src/types/spam_report.rs @@ -124,6 +124,7 @@ pub fn build_spam_list_node(request: &SpamReportRequest) -> Node { } #[cfg(test)] +#[allow(deprecated)] mod tests { use super::*; diff --git a/wacore/src/usync.rs b/wacore/src/usync.rs index fb467bcd5..0a65399c0 100644 --- a/wacore/src/usync.rs +++ b/wacore/src/usync.rs @@ -74,7 +74,13 @@ pub fn parse_get_user_devices_response_with_phash(resp_node: &Node) -> Result id, + None => { + log::warn!(target: "usync", "device node missing 'id' attribute, skipping"); + continue; + } + }; let device_id: u16 = device_id_str.parse()?; let mut device_jid = user_jid.clone(); @@ -112,7 +118,10 @@ pub fn parse_lid_mappings_from_response(resp_node: &Node) -> Vec jid, + None => continue, + }; let user_jid: Jid = match user_jid_str.parse() { Ok(j) => j, Err(_) => continue, @@ -125,7 +134,10 @@ pub fn parse_lid_mappings_from_response(resp_node: &Node) -> Vec node inside the user node if let Some(lid_node) = user_node.get_optional_child("lid") { - let lid_val = lid_node.attrs().string("val"); + let lid_val = match lid_node.attrs().optional_string("val") { + Some(v) => v, + None => continue, + }; if !lid_val.is_empty() { // Parse the LID JID to extract just the user part if let Ok(lid_jid) = lid_val.parse::() diff --git a/wacore/tests/binary_protocol_test.rs b/wacore/tests/binary_protocol_test.rs index 582734563..ce8e32a88 100644 --- a/wacore/tests/binary_protocol_test.rs +++ b/wacore/tests/binary_protocol_test.rs @@ -30,6 +30,7 @@ fn test_node_with_attributes_and_content_with_ref() { } #[test] +#[allow(deprecated)] fn test_attr_parser_ref_zero_copy_access() { let original_node = NodeBuilder::new("iq") .attrs([("xmlns", "test"), ("type", "result"), ("id", "123")]) From 0bbb84078ea4056b6baf0bad28823d3e70adef00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 12:49:26 -0300 Subject: [PATCH 05/18] chore: add validated string macro and node helpers --- wacore/src/iq/groups.rs | 86 +++--------------- wacore/src/iq/node.rs | 78 ++++++++++++++++ wacore/src/iq/usync.rs | 191 +++++++++++++++++++++------------------- wacore/src/protocol.rs | 74 ++++++++++++++++ wacore/src/usync.rs | 8 +- 5 files changed, 273 insertions(+), 164 deletions(-) diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 51add31b3..db8cc0d7f 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -1,5 +1,5 @@ use crate::StringEnum; -use crate::iq::node::{optional_attr, required_attr, required_child}; +use crate::iq::node::{collect_children, optional_attr, required_attr, required_child}; use crate::iq::spec::IqSpec; use crate::protocol::ProtocolNode; use crate::request::InfoQuery; @@ -88,70 +88,18 @@ impl TryFrom> for ParticipantType { } } } -/// A validated group subject string. -/// -/// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GroupSubject(String); - -impl GroupSubject { - /// Create a new validated group subject. - pub fn new(subject: impl Into) -> Result { - let s = subject.into(); - if s.chars().count() > GROUP_SUBJECT_MAX_LENGTH { - return Err(anyhow!( - "Group subject exceeds {} characters", - GROUP_SUBJECT_MAX_LENGTH - )); - } - Ok(Self(s)) - } - - /// Create a group subject without validation (for parsing responses). - pub fn new_unchecked(subject: impl Into) -> Self { - Self(subject.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_string(self) -> String { - self.0 - } +crate::define_validated_string! { + /// A validated group subject string. + /// + /// WhatsApp limits group subjects to [`GROUP_SUBJECT_MAX_LENGTH`] characters. + pub struct GroupSubject(max_len = GROUP_SUBJECT_MAX_LENGTH, name = "Group subject") } -/// A validated group description string. -/// -/// WhatsApp limits group descriptions to [`GROUP_DESCRIPTION_MAX_LENGTH`] characters. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct GroupDescription(String); - -impl GroupDescription { - /// Create a new validated group description. - pub fn new(description: impl Into) -> Result { - let s = description.into(); - if s.chars().count() > GROUP_DESCRIPTION_MAX_LENGTH { - return Err(anyhow!( - "Group description exceeds {} characters", - GROUP_DESCRIPTION_MAX_LENGTH - )); - } - Ok(Self(s)) - } - - /// Create a group description without validation (for parsing responses). - pub fn new_unchecked(description: impl Into) -> Self { - Self(description.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_string(self) -> String { - self.0 - } +crate::define_validated_string! { + /// A validated group description string. + /// + /// WhatsApp limits group descriptions to [`GROUP_DESCRIPTION_MAX_LENGTH`] characters. + pub struct GroupDescription(max_len = GROUP_DESCRIPTION_MAX_LENGTH, name = "Group description") } /// Options for a participant when creating a group. #[derive(Debug, Clone, TypedBuilder)] @@ -464,11 +412,7 @@ impl ProtocolNode for GroupInfoResponse { let addressing_mode = AddressingMode::try_from(optional_attr(node, "addressing_mode").unwrap_or("pn"))?; - let participants = node - .get_children_by_tag("participant") - .iter() - .map(|child| GroupParticipantResponse::try_from_node(child)) - .collect::>>()?; + let participants = collect_children::(node, "participant")?; Ok(Self { id, @@ -545,11 +489,7 @@ impl ProtocolNode for GroupParticipatingResponse { return Err(anyhow!("expected , got <{}>", node.tag)); } - let groups = node - .get_children_by_tag("group") - .iter() - .map(|child| GroupInfoResponse::try_from_node(child)) - .collect::>>()?; + let groups = collect_children::(node, "group")?; Ok(Self { groups }) } diff --git a/wacore/src/iq/node.rs b/wacore/src/iq/node.rs index 71e954ec9..71049b4cb 100644 --- a/wacore/src/iq/node.rs +++ b/wacore/src/iq/node.rs @@ -3,6 +3,7 @@ //! These functions provide a consistent way to extract required and optional //! children/attributes from protocol nodes with clear error messages. +use crate::protocol::ProtocolNode; use anyhow::anyhow; use wacore_binary::jid::Jid; use wacore_binary::node::Node; @@ -49,3 +50,80 @@ pub fn optional_jid(node: &Node, key: &str) -> Result, anyhow::Error None => Ok(None), } } + +/// Get optional string content from a child node, skipping if an error child exists. +/// +/// This is a common pattern in usync responses where a node may contain +/// an `` child to indicate the data is unavailable. +pub fn optional_string_content(node: &Node, child_tag: &str) -> Option { + use wacore_binary::node::NodeContent; + + node.get_optional_child(child_tag).and_then(|child| { + if child.get_optional_child("error").is_some() { + return None; + } + match &child.content { + Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } + }) +} + +/// Get optional JID from a child node's attribute (commonly "val"). +/// +/// Example: `` -> returns parsed JID +pub fn optional_jid_from_child(node: &Node, child_tag: &str, attr: &str) -> Option { + node.get_optional_child(child_tag) + .and_then(|n| n.attrs().optional_string(attr)) + .and_then(|s| s.parse().ok()) +} + +/// Get optional string attribute from a child node, skipping if an error child exists. +pub fn optional_attr_skipping_error(node: &Node, child_tag: &str, attr: &str) -> Option { + node.get_optional_child(child_tag).and_then(|child| { + if child.get_optional_child("error").is_some() { + return None; + } + child.attrs().optional_string(attr).map(|s| s.to_string()) + }) +} + +/// Parse all children with a given tag into a Vec of ProtocolNodes. +/// +/// Returns an error if any child fails to parse. +/// +/// # Example +/// ```ignore +/// let participants = collect_children::(node, "participant")?; +/// ``` +pub fn collect_children(node: &Node, tag: &str) -> Result, anyhow::Error> { + node.get_children_by_tag(tag) + .iter() + .map(|child| T::try_from_node(child)) + .collect() +} + +/// Parse all children with a given tag into a Vec of ProtocolNodes, skipping parse errors. +/// +/// Logs a warning for each child that fails to parse. +/// +/// # Example +/// ```ignore +/// let entries = collect_children_lenient::(node, "item"); +/// ``` +pub fn collect_children_lenient(node: &Node, tag: &str) -> Vec { + node.get_children_by_tag(tag) + .iter() + .filter_map(|child| match T::try_from_node(child) { + Ok(item) => Some(item), + Err(e) => { + log::warn!( + target: "iq::node", + "Failed to parse <{}>: {e}", + tag + ); + None + } + }) + .collect() +} diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs index 6e7cc44c3..9f912b5dc 100644 --- a/wacore/src/iq/usync.rs +++ b/wacore/src/iq/usync.rs @@ -113,6 +113,93 @@ fn build_phone_user_nodes(phones: &[String]) -> Vec { .collect() } +// ============================================================================ +// User Node Parsing Helpers +// ============================================================================ + +/// Common fields parsed from a usync user node. +struct ParsedUserFields { + jid: Jid, + lid: Option, + is_registered: bool, + is_business: bool, + status: Option, +} + +/// Parse common fields from a usync `` node. +fn parse_user_common_fields(user_node: &Node) -> Option { + let jid = user_node + .attrs() + .optional_string("jid")? + .parse::() + .ok()?; + + let contact_node = user_node.get_optional_child("contact"); + let is_registered = contact_node + .map(|c| c.attrs().optional_string("type") == Some("in")) + .unwrap_or(false); + + let lid = user_node.get_optional_child("lid").and_then(|lid_node| { + lid_node + .attrs() + .optional_string("val") + .and_then(|val| val.parse::().ok()) + }); + + let status = user_node + .get_optional_child("status") + .and_then(|status_node| { + if status_node.get_optional_child("error").is_some() { + return None; + } + match &status_node.content { + Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } + }); + + let is_business = user_node.get_optional_child("business").is_some(); + + Some(ParsedUserFields { + jid, + lid, + is_registered, + is_business, + status, + }) +} + +/// Parse picture ID as u64 (used in ContactInfo). +fn parse_picture_id_u64(user_node: &Node) -> Option { + user_node + .get_optional_child("picture") + .and_then(|pic_node| { + if pic_node.get_optional_child("error").is_some() { + return None; + } + pic_node.attrs().optional_u64("id") + }) +} + +/// Parse picture ID as String (used in UserInfo). +fn parse_picture_id_string(user_node: &Node) -> Option { + user_node + .get_optional_child("picture") + .and_then(|pic_node| { + if pic_node.get_optional_child("error").is_some() { + return None; + } + pic_node + .attrs() + .optional_string("id") + .map(|s| s.to_string()) + }) +} + +// ============================================================================ +// Types +// ============================================================================ + /// Result of checking if a phone number is on WhatsApp. #[derive(Debug, Clone)] pub struct IsOnWhatsAppResult { @@ -280,53 +367,14 @@ impl IqSpec for ContactInfoSpec { let mut results = Vec::new(); for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let contact_node = user_node.get_optional_child("contact"); - let is_registered = contact_node - .map(|c| c.attrs().optional_string("type") == Some("in")) - .unwrap_or(false); - - let lid = user_node.get_optional_child("lid").and_then(|lid_node| { - lid_node - .attrs() - .optional_string("val") - .and_then(|val| val.parse::().ok()) - }); - - let status = user_node - .get_optional_child("status") - .and_then(|status_node| { - if status_node.get_optional_child("error").is_some() { - return None; - } - match &status_node.content { - Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), - _ => None, - } - }); - - let picture_id = user_node - .get_optional_child("picture") - .and_then(|pic_node| { - if pic_node.get_optional_child("error").is_some() { - return None; - } - pic_node.attrs().optional_u64("id") - }); - - let is_business = user_node.get_optional_child("business").is_some(); - + if let Some(fields) = parse_user_common_fields(user_node) { results.push(ContactInfo { - jid, - lid, - is_registered, - is_business, - status, - picture_id, + jid: fields.jid, + lid: fields.lid, + is_registered: fields.is_registered, + is_business: fields.is_business, + status: fields.status, + picture_id: parse_picture_id_u64(user_node), }); } } @@ -407,52 +455,15 @@ impl IqSpec for UserInfoSpec { let mut results = HashMap::new(); for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let lid = user_node.get_optional_child("lid").and_then(|lid_node| { - lid_node - .attrs() - .optional_string("val") - .and_then(|val| val.parse::().ok()) - }); - - let status = user_node - .get_optional_child("status") - .and_then(|status_node| { - if status_node.get_optional_child("error").is_some() { - return None; - } - match &status_node.content { - Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()), - _ => None, - } - }); - - let picture_id = user_node - .get_optional_child("picture") - .and_then(|pic_node| { - if pic_node.get_optional_child("error").is_some() { - return None; - } - pic_node - .attrs() - .optional_string("id") - .map(|s| s.to_string()) - }); - - let is_business = user_node.get_optional_child("business").is_some(); - + if let Some(fields) = parse_user_common_fields(user_node) { results.insert( - jid.clone(), + fields.jid.clone(), UserInfo { - jid, - lid, - status, - picture_id, - is_business, + jid: fields.jid, + lid: fields.lid, + status: fields.status, + picture_id: parse_picture_id_string(user_node), + is_business: fields.is_business, }, ); } diff --git a/wacore/src/protocol.rs b/wacore/src/protocol.rs index 8168e1671..c3c69a062 100644 --- a/wacore/src/protocol.rs +++ b/wacore/src/protocol.rs @@ -138,3 +138,77 @@ macro_rules! define_empty_node { } }; } + +/// Macro for defining validated string newtypes with a maximum length constraint. +/// +/// This generates a newtype wrapper around `String` that validates length on construction. +/// +/// # Example +/// +/// ```ignore +/// define_validated_string! { +/// /// A validated group subject with 100 character limit. +/// pub struct GroupSubject(max_len = GROUP_SUBJECT_MAX_LENGTH, name = "Group subject"); +/// } +/// ``` +/// +/// This generates: +/// - A tuple struct wrapping `String` +/// - `new(s: impl Into) -> Result` that validates length +/// - `new_unchecked(s: impl Into) -> Self` for parsing responses +/// - `as_str() -> &str` +/// - `into_string() -> String` +/// - Derives: `Debug, Clone, PartialEq, Eq, Hash` +#[macro_export] +macro_rules! define_validated_string { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident(max_len = $max_len:expr, name = $display_name:literal) + ) => { + $(#[$meta])* + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + $vis struct $name(String); + + impl $name { + /// Create a new validated string, returning an error if it exceeds the maximum length. + pub fn new(value: impl Into) -> anyhow::Result { + let s = value.into(); + if s.chars().count() > $max_len { + return Err(anyhow::anyhow!( + "{} exceeds {} characters", + $display_name, + $max_len + )); + } + Ok(Self(s)) + } + + /// Create a new string without validation (for parsing responses). + pub fn new_unchecked(value: impl Into) -> Self { + Self(value.into()) + } + + /// Get the string as a slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume self and return the inner string. + pub fn into_string(self) -> String { + self.0 + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + }; +} diff --git a/wacore/src/usync.rs b/wacore/src/usync.rs index 0a65399c0..c59d5c886 100644 --- a/wacore/src/usync.rs +++ b/wacore/src/usync.rs @@ -81,7 +81,13 @@ pub fn parse_get_user_devices_response_with_phash(resp_node: &Node) -> Result id, + Err(_) => { + log::warn!(target: "usync", "invalid device id '{device_id_str}' for user {user_jid}, skipping"); + continue; + } + }; let mut device_jid = user_jid.clone(); device_jid.device = device_id; From 3079a41b50f2f90890d6f19cd10c83f9d0d0372d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 13:27:37 -0300 Subject: [PATCH 06/18] chore: Use &Jid in IQ APIs and add InfoQuery ref helpers --- src/client.rs | 9 --------- src/features/groups.rs | 4 ++-- src/message.rs | 3 --- src/request.rs | 2 +- wacore/src/iq/contacts.rs | 2 +- wacore/src/iq/groups.rs | 10 ++++++---- wacore/src/iq/usync.rs | 8 -------- wacore/src/request.rs | 15 +++++++++++++++ wacore/src/send.rs | 4 ---- wacore/src/store/traits.rs | 24 ------------------------ 10 files changed, 25 insertions(+), 56 deletions(-) diff --git a/src/client.rs b/src/client.rs index a1d5c4dba..a40cf49c6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2297,15 +2297,6 @@ mod tests { ); } - // ========================================================================= - // PDO Session Establishment Timing Tests - // ========================================================================= - // These tests verify the critical timing behavior for PDO: - // - Session with device 0 must be established BEFORE offline messages arrive - // - ensure_e2e_sessions() waits for offline sync (for normal message sending) - // - establish_primary_phone_session_immediate() does NOT wait (for login) - // ========================================================================= - /// Test that wait_for_offline_delivery_end returns immediately when the flag is already set. #[tokio::test] async fn test_wait_for_offline_delivery_end_returns_immediately_when_flag_set() { diff --git a/src/features/groups.rs b/src/features/groups.rs index e7504c549..fbb7309a2 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -57,7 +57,7 @@ impl<'a> Groups<'a> { return Ok(cached); } - let group = self.client.execute(GroupQueryIq::new(jid.clone())).await?; + let group = self.client.execute(GroupQueryIq::new(jid)).await?; let participants: Vec = group.participants.iter().map(|p| p.jid.clone()).collect(); @@ -111,7 +111,7 @@ impl<'a> Groups<'a> { } pub async fn get_metadata(&self, jid: &Jid) -> Result { - let group = self.client.execute(GroupQueryIq::new(jid.clone())).await?; + let group = self.client.execute(GroupQueryIq::new(jid)).await?; Ok(GroupMetadata { id: group.id, diff --git a/src/message.rs b/src/message.rs index d6ec1bb8f..0c86f8df1 100644 --- a/src/message.rs +++ b/src/message.rs @@ -3480,9 +3480,6 @@ mod tests { println!(" - Protocol address: {}", protocol_address); } - // ==================== RETRY LOGIC TESTS ==================== - // - // These tests verify the retry count tracking, max retry limits, // and PDO fallback behavior to ensure robust message recovery. /// Helper to create a test MessageInfo with customizable fields diff --git a/src/request.rs b/src/request.rs index fa83c551d..d0ad11084 100644 --- a/src/request.rs +++ b/src/request.rs @@ -172,7 +172,7 @@ impl Client { /// ```ignore /// use wacore::iq::groups::GroupQueryIq; /// - /// let group_info = client.execute(GroupQueryIq::new(group_jid)).await?; + /// let group_info = client.execute(GroupQueryIq::new(&group_jid)).await?; /// println!("Group subject: {}", group_info.subject); /// ``` pub async fn execute(&self, spec: S) -> Result diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs index 2ce7cf1f2..f8592c13d 100644 --- a/wacore/src/iq/contacts.rs +++ b/wacore/src/iq/contacts.rs @@ -96,7 +96,7 @@ impl IqSpec for ProfilePictureSpec { Jid::new("", SERVER_JID), Some(NodeContent::Nodes(vec![picture_node])), ) - .with_target(self.jid.clone()) + .with_target_ref(&self.jid) } fn parse_response(&self, response: &Node) -> Result { diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index db8cc0d7f..6c3eaed67 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -501,8 +501,10 @@ pub struct GroupQueryIq { } impl GroupQueryIq { - pub fn new(group_jid: Jid) -> Self { - Self { group_jid } + pub fn new(group_jid: &Jid) -> Self { + Self { + group_jid: group_jid.clone(), + } } } @@ -510,9 +512,9 @@ impl IqSpec for GroupQueryIq { type Response = GroupInfoResponse; fn build_iq(&self) -> InfoQuery<'static> { - InfoQuery::get( + InfoQuery::get_ref( GROUP_IQ_NAMESPACE, - self.group_jid.clone(), + &self.group_jid, Some(NodeContent::Nodes(vec![ GroupQueryRequest::default().into_node(), ])), diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs index 9f912b5dc..0cb0b4af7 100644 --- a/wacore/src/iq/usync.rs +++ b/wacore/src/iq/usync.rs @@ -113,10 +113,6 @@ fn build_phone_user_nodes(phones: &[String]) -> Vec { .collect() } -// ============================================================================ -// User Node Parsing Helpers -// ============================================================================ - /// Common fields parsed from a usync user node. struct ParsedUserFields { jid: Jid, @@ -196,10 +192,6 @@ fn parse_picture_id_string(user_node: &Node) -> Option { }) } -// ============================================================================ -// Types -// ============================================================================ - /// Result of checking if a phone number is on WhatsApp. #[derive(Debug, Clone)] pub struct IsOnWhatsAppResult { diff --git a/wacore/src/request.rs b/wacore/src/request.rs index 8b54dc1c4..23e5f7863 100644 --- a/wacore/src/request.rs +++ b/wacore/src/request.rs @@ -61,6 +61,21 @@ impl<'a> InfoQuery<'a> { self.timeout = Some(timeout); self } + + /// Create a GET query from a Jid reference (avoids clone at call site). + pub fn get_ref(namespace: &'a str, to: &Jid, content: Option) -> Self { + Self::get(namespace, to.clone(), content) + } + + /// Create a SET query from a Jid reference (avoids clone at call site). + pub fn set_ref(namespace: &'a str, to: &Jid, content: Option) -> Self { + Self::set(namespace, to.clone(), content) + } + + /// Set target from a Jid reference (avoids clone at call site). + pub fn with_target_ref(self, target: &Jid) -> Self { + self.with_target(target.clone()) + } } #[derive(Debug, Error)] diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 034fc1206..2bf32c6f5 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -1282,10 +1282,6 @@ mod tests { .expect("Failed to create PreKeyBundle") } - // ========================================== - // LID-PN Session Mismatch Fix Tests - // ========================================== - // // These tests validate the fix for the LID-PN session mismatch issue. // When a message is received with sender_lid, the session is stored under the LID address. // When sending a reply using the phone number, we must reuse the existing LID session diff --git a/wacore/src/store/traits.rs b/wacore/src/store/traits.rs index c4487d59d..a0319ff58 100644 --- a/wacore/src/store/traits.rs +++ b/wacore/src/store/traits.rs @@ -13,10 +13,6 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use wacore_appstate::processor::AppStateMutationMAC; -// ============================================================================ -// Data Structures -// ============================================================================ - /// App state synchronization key for WhatsApp's app state protocol. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AppStateSyncKey { @@ -62,10 +58,6 @@ pub struct DeviceListRecord { pub phash: Option, } -// ============================================================================ -// SignalStore - Signal Protocol Cryptographic Operations -// ============================================================================ - /// Signal protocol cryptographic storage operations. /// /// Handles identity keys, sessions, pre-keys, signed pre-keys, and sender keys @@ -136,10 +128,6 @@ pub trait SignalStore: Send + Sync { async fn delete_sender_key(&self, address: &str) -> Result<()>; } -// ============================================================================ -// AppSyncStore - WhatsApp App State Synchronization -// ============================================================================ - /// WhatsApp app state synchronization storage. /// /// Handles sync keys, version tracking, and mutation MACs for the app state protocol. @@ -172,10 +160,6 @@ pub trait AppSyncStore: Send + Sync { async fn delete_mutation_macs(&self, name: &str, index_macs: &[Vec]) -> Result<()>; } -// ============================================================================ -// ProtocolStore - WhatsApp Web Protocol Alignment -// ============================================================================ - /// WhatsApp Web protocol alignment storage. /// /// Handles SKDM tracking, LID-PN mapping, base key collision detection, @@ -241,10 +225,6 @@ pub trait ProtocolStore: Send + Sync { async fn consume_forget_marks(&self, group_jid: &str) -> Result>; } -// ============================================================================ -// DeviceStore - Device Persistence -// ============================================================================ - /// Device data persistence operations. #[async_trait] pub trait DeviceStore: Send + Sync { @@ -261,10 +241,6 @@ pub trait DeviceStore: Send + Sync { async fn create(&self) -> Result; } -// ============================================================================ -// Backend - Combined Trait -// ============================================================================ - /// Combined storage backend trait. /// /// Any type implementing all four domain traits automatically implements `Backend`. From de81b18ccc2285ef431aa2677af05cbd7c25c2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 13:35:17 -0300 Subject: [PATCH 07/18] fix: increase group description max length --- AGENTS.md | 2 +- wacore/src/iq/groups.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70ce2a444..a81852abe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,7 @@ impl GroupSubject { Constants from WhatsApp Web A/B props (`wacore/src/iq/groups.rs`): - `GROUP_SUBJECT_MAX_LENGTH`: 100 characters -- `GROUP_DESCRIPTION_MAX_LENGTH`: 512 characters +- `GROUP_DESCRIPTION_MAX_LENGTH`: 2048 characters - `GROUP_SIZE_LIMIT`: 257 participants ### Strongly Typed Enums diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 6c3eaed67..ae7bce42d 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -18,7 +18,7 @@ pub const GROUP_IQ_NAMESPACE: &str = "w:g2"; pub const GROUP_SUBJECT_MAX_LENGTH: usize = 100; /// Maximum length for a WhatsApp group description (from `group_description_length` A/B prop). -pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 512; +pub const GROUP_DESCRIPTION_MAX_LENGTH: usize = 2048; /// Maximum number of participants in a group (from `group_size_limit` A/B prop). pub const GROUP_SIZE_LIMIT: usize = 257; From 0a9f421b22fef90ef8c4c9b46477209b0791f776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 13:49:25 -0300 Subject: [PATCH 08/18] fix: Compare only JID user when checking blocklist --- src/features/blocking.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 60f3c9ddb..9c39057ba 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -48,9 +48,12 @@ impl<'a> Blocking<'a> { } /// Check if a contact is blocked. + /// + /// Compares only the user part of the JID, ignoring device ID, + /// since blocking applies to the entire user account, not individual devices. pub async fn is_blocked(&self, jid: &Jid) -> Result { let blocklist = self.get_blocklist().await?; - Ok(blocklist.iter().any(|e| &e.jid == jid)) + Ok(blocklist.iter().any(|e| e.jid.user == jid.user)) } } From 70ec4bddebb396ec8c5d7c6a3073b8ebf23ab94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 14:10:32 -0300 Subject: [PATCH 09/18] chore: Use IqSpec specs for client IQ requests --- src/client.rs | 62 ++------ src/keepalive.rs | 14 +- wacore/src/iq/dirty.rs | 230 ++++++++++++++++++++++++++++ wacore/src/iq/keepalive.rs | 31 +++- wacore/src/iq/mod.rs | 4 + wacore/src/iq/passive.rs | 119 +++++++++++++++ wacore/src/iq/privacy.rs | 300 +++++++++++++++++++++++++++++++++++++ wacore/src/iq/props.rs | 263 ++++++++++++++++++++++++++++++++ 8 files changed, 962 insertions(+), 61 deletions(-) create mode 100644 wacore/src/iq/dirty.rs create mode 100644 wacore/src/iq/passive.rs create mode 100644 wacore/src/iq/privacy.rs create mode 100644 wacore/src/iq/props.rs diff --git a/src/client.rs b/src/client.rs index a40cf49c6..55a383791 100644 --- a/src/client.rs +++ b/src/client.rs @@ -759,76 +759,36 @@ impl Client { } pub async fn set_passive(&self, passive: bool) -> Result<(), crate::request::IqError> { - use crate::request::InfoQuery; - - let tag = if passive { "passive" } else { "active" }; - - let query = InfoQuery::set( - "passive", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![ - NodeBuilder::new(tag).build(), - ])), - ); - - self.send_iq(query).await.map(|_| ()) + use wacore::iq::passive::PassiveModeSpec; + self.execute(PassiveModeSpec::new(passive)).await } pub async fn clean_dirty_bits( &self, type_: &str, timestamp: Option<&str>, - ) -> Result<(), ClientError> { - let id = self.generate_request_id(); - let mut clean_builder = NodeBuilder::new("clean").attr("type", type_); - if let Some(ts) = timestamp { - clean_builder = clean_builder.attr("timestamp", ts); - } - - let node = NodeBuilder::new("iq") - .attr("to", server_jid().to_string()) - .attr("type", "set") - .attr("xmlns", "urn:xmpp:whatsapp:dirty") - .attr("id", id) - .children([clean_builder.build()]) - .build(); + ) -> Result<(), crate::request::IqError> { + use wacore::iq::dirty::CleanDirtyBitsSpec; - self.send_node(node).await + self.execute(CleanDirtyBitsSpec::single(type_, timestamp)) + .await } pub async fn fetch_props(&self) -> Result<(), crate::request::IqError> { - use crate::request::InfoQuery; + use wacore::iq::props::PropsSpec; debug!(target: "Client", "Fetching properties (props)..."); - let props_node = NodeBuilder::new("props") - .attr("protocol", "2") - .attr("hash", "") // TODO: load hash from persistence - .build(); - - let iq = InfoQuery::get( - "w", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![props_node])), - ); - - self.send_iq(iq).await.map(|_| ()) + // TODO: load hash from persistence for delta updates + self.execute(PropsSpec::new()).await.map(|_| ()) } pub async fn fetch_privacy_settings(&self) -> Result<(), crate::request::IqError> { - use crate::request::InfoQuery; + use wacore::iq::privacy::PrivacySettingsSpec; debug!(target: "Client", "Fetching privacy settings..."); - let iq = InfoQuery::get( - "privacy", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![ - NodeBuilder::new("privacy").build(), - ])), - ); - - self.send_iq(iq).await.map(|_| ()) + self.execute(PrivacySettingsSpec::new()).await.map(|_| ()) } pub async fn send_digest_key_bundle(&self) -> Result<(), crate::request::IqError> { diff --git a/src/keepalive.rs b/src/keepalive.rs index 2be71ea3d..cb967ec08 100644 --- a/src/keepalive.rs +++ b/src/keepalive.rs @@ -1,11 +1,11 @@ use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::{InfoQuery, IqError}; +use crate::request::IqError; use log::{debug, info, warn}; use rand::Rng; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; +use wacore::iq::keepalive::KeepaliveSpec; const KEEP_ALIVE_INTERVAL_MIN: Duration = Duration::from_secs(20); const KEEP_ALIVE_INTERVAL_MAX: Duration = Duration::from_secs(30); @@ -20,11 +20,11 @@ impl Client { info!(target: "Client/Keepalive", "Sending keepalive ping"); - let iq = - InfoQuery::get("w:p", server_jid(), None).with_timeout(KEEP_ALIVE_RESPONSE_DEADLINE); - - match self.send_iq(iq).await { - Ok(_) => { + match self + .execute(KeepaliveSpec::with_timeout(KEEP_ALIVE_RESPONSE_DEADLINE)) + .await + { + Ok(()) => { debug!(target: "Client/Keepalive", "Received keepalive pong"); true } diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs new file mode 100644 index 000000000..c5dd21efa --- /dev/null +++ b/wacore/src/iq/dirty.rs @@ -0,0 +1,230 @@ +//! Dirty bits IQ specification. +//! +//! Used to acknowledge and clear "dirty bits" - flags indicating pending server-side data +//! that needs to be synced (contacts, account settings, etc.). +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! ``` +//! +//! Verified against WhatsApp Web JS (clearDirtyBits in 5Yec01dI04o.js). + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// IQ namespace for dirty bits. +pub const DIRTY_NAMESPACE: &str = "urn:xmpp:whatsapp:dirty"; + +/// Known dirty bit types. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DirtyType { + /// Account sync dirty bit + AccountSync, + /// Groups dirty bit + Groups, + /// Other/unknown type + Other(String), +} + +impl DirtyType { + pub fn as_str(&self) -> &str { + match self { + DirtyType::AccountSync => "account_sync", + DirtyType::Groups => "groups", + DirtyType::Other(s) => s.as_str(), + } + } +} + +impl From<&str> for DirtyType { + fn from(s: &str) -> Self { + match s { + "account_sync" => DirtyType::AccountSync, + "groups" => DirtyType::Groups, + other => DirtyType::Other(other.to_string()), + } + } +} + +/// A dirty bit to clean. +#[derive(Debug, Clone)] +pub struct DirtyBit { + /// The type of dirty bit. + pub dirty_type: DirtyType, + /// Optional timestamp for the dirty bit. + pub timestamp: Option, +} + +impl DirtyBit { + /// Create a new dirty bit with just a type. + pub fn new(dirty_type: impl Into) -> Self { + Self { + dirty_type: dirty_type.into(), + timestamp: None, + } + } + + /// Create a new dirty bit with a type and timestamp. + pub fn with_timestamp(dirty_type: impl Into, timestamp: u64) -> Self { + Self { + dirty_type: dirty_type.into(), + timestamp: Some(timestamp), + } + } +} + +/// Clears dirty bits on the server. +#[derive(Debug, Clone)] +pub struct CleanDirtyBitsSpec { + /// The dirty bits to clean. + pub bits: Vec, +} + +impl CleanDirtyBitsSpec { + /// Create a spec to clean a single dirty bit. + pub fn single(dirty_type: &str, timestamp: Option<&str>) -> Self { + let bit = if let Some(ts) = timestamp { + if let Ok(ts_num) = ts.parse() { + DirtyBit::with_timestamp(DirtyType::from(dirty_type), ts_num) + } else { + DirtyBit::new(DirtyType::from(dirty_type)) + } + } else { + DirtyBit::new(DirtyType::from(dirty_type)) + }; + Self { bits: vec![bit] } + } + + /// Create a spec to clean multiple dirty bits. + pub fn multiple(bits: Vec) -> Self { + Self { bits } + } +} + +impl IqSpec for CleanDirtyBitsSpec { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + let children: Vec = self + .bits + .iter() + .map(|bit| { + let mut builder = + NodeBuilder::new("clean").attr("type", bit.dirty_type.as_str().to_string()); + if let Some(ts) = bit.timestamp { + builder = builder.attr("timestamp", ts.to_string()); + } + builder.build() + }) + .collect(); + + InfoQuery::set( + DIRTY_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(children)), + ) + } + + fn parse_response(&self, _response: &Node) -> Result { + // Clean dirty bits just needs a successful response + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_dirty_bits_spec_single() { + let spec = CleanDirtyBitsSpec::single("account_sync", None); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, DIRTY_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "clean"); + assert_eq!( + nodes[0].attrs.get("type"), + Some(&"account_sync".to_string()) + ); + assert!(nodes[0].attrs.get("timestamp").is_none()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_clean_dirty_bits_spec_with_timestamp() { + let spec = CleanDirtyBitsSpec::single("groups", Some("1234567890")); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].attrs.get("type"), Some(&"groups".to_string())); + assert_eq!( + nodes[0].attrs.get("timestamp"), + Some(&"1234567890".to_string()) + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_clean_dirty_bits_spec_multiple() { + let bits = vec![ + DirtyBit::new(DirtyType::AccountSync), + DirtyBit::with_timestamp(DirtyType::Groups, 9876543210), + ]; + let spec = CleanDirtyBitsSpec::multiple(bits); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 2); + assert_eq!( + nodes[0].attrs.get("type"), + Some(&"account_sync".to_string()) + ); + assert!(nodes[0].attrs.get("timestamp").is_none()); + assert_eq!(nodes[1].attrs.get("type"), Some(&"groups".to_string())); + assert_eq!( + nodes[1].attrs.get("timestamp"), + Some(&"9876543210".to_string()) + ); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_clean_dirty_bits_spec_parse_response() { + let spec = CleanDirtyBitsSpec::single("account_sync", None); + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response); + assert!(result.is_ok()); + } + + #[test] + fn test_dirty_type_from_str() { + assert_eq!(DirtyType::from("account_sync"), DirtyType::AccountSync); + assert_eq!(DirtyType::from("groups"), DirtyType::Groups); + assert_eq!( + DirtyType::from("other"), + DirtyType::Other("other".to_string()) + ); + } +} diff --git a/wacore/src/iq/keepalive.rs b/wacore/src/iq/keepalive.rs index fcff9b3e4..db154e0be 100644 --- a/wacore/src/iq/keepalive.rs +++ b/wacore/src/iq/keepalive.rs @@ -11,16 +11,27 @@ use crate::iq::spec::IqSpec; use crate::request::InfoQuery; +use std::time::Duration; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::Node; /// Keepalive ping to keep the connection alive. #[derive(Debug, Clone, Default)] -pub struct KeepaliveSpec; +pub struct KeepaliveSpec { + /// Optional timeout for the keepalive response. + pub timeout: Option, +} impl KeepaliveSpec { pub fn new() -> Self { - Self + Self { timeout: None } + } + + /// Create a keepalive spec with a custom timeout. + pub fn with_timeout(timeout: Duration) -> Self { + Self { + timeout: Some(timeout), + } } } @@ -28,7 +39,11 @@ impl IqSpec for KeepaliveSpec { type Response = (); fn build_iq(&self) -> InfoQuery<'static> { - InfoQuery::get("w:p", Jid::new("", SERVER_JID), None) + let mut iq = InfoQuery::get("w:p", Jid::new("", SERVER_JID), None); + if let Some(timeout) = self.timeout { + iq = iq.with_timeout(timeout); + } + iq } fn parse_response(&self, _response: &Node) -> Result { @@ -50,6 +65,16 @@ mod tests { assert_eq!(iq.namespace, "w:p"); assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); assert!(iq.content.is_none()); + assert!(iq.timeout.is_none()); + } + + #[test] + fn test_keepalive_spec_with_timeout() { + let spec = KeepaliveSpec::with_timeout(Duration::from_secs(20)); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "w:p"); + assert_eq!(iq.timeout, Some(Duration::from_secs(20))); } #[test] diff --git a/wacore/src/iq/mod.rs b/wacore/src/iq/mod.rs index a6c3ab362..64395bd48 100644 --- a/wacore/src/iq/mod.rs +++ b/wacore/src/iq/mod.rs @@ -1,11 +1,15 @@ pub mod blocklist; pub mod contacts; +pub mod dirty; pub mod groups; pub mod keepalive; pub mod mediaconn; pub mod mex; pub mod node; +pub mod passive; pub mod prekeys; +pub mod privacy; +pub mod props; pub mod spam_report; pub mod spec; pub mod usync; diff --git a/wacore/src/iq/passive.rs b/wacore/src/iq/passive.rs new file mode 100644 index 000000000..faaee46b7 --- /dev/null +++ b/wacore/src/iq/passive.rs @@ -0,0 +1,119 @@ +//! Passive mode IQ specification. +//! +//! Passive mode tells the server whether the client is actively receiving +//! notifications or is in a background/passive state. +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ``` + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// IQ namespace for passive mode. +pub const PASSIVE_NAMESPACE: &str = "passive"; + +/// Sets the client's passive/active mode. +#[derive(Debug, Clone)] +pub struct PassiveModeSpec { + /// Whether to set passive mode (true) or active mode (false). + pub passive: bool, +} + +impl PassiveModeSpec { + /// Create a spec to set passive mode (background). + pub fn passive() -> Self { + Self { passive: true } + } + + /// Create a spec to set active mode (foreground). + pub fn active() -> Self { + Self { passive: false } + } + + /// Create a spec from a boolean (true = passive, false = active). + pub fn new(passive: bool) -> Self { + Self { passive } + } +} + +impl IqSpec for PassiveModeSpec { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + let tag = if self.passive { "passive" } else { "active" }; + let child_node = NodeBuilder::new(tag).build(); + + InfoQuery::set( + PASSIVE_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![child_node])), + ) + } + + fn parse_response(&self, _response: &Node) -> Result { + // Passive mode just needs a successful response + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_passive_mode_spec_passive() { + let spec = PassiveModeSpec::passive(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, PASSIVE_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "passive"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_passive_mode_spec_active() { + let spec = PassiveModeSpec::active(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, PASSIVE_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "active"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_passive_mode_spec_parse_response() { + let spec = PassiveModeSpec::passive(); + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response); + assert!(result.is_ok()); + } +} diff --git a/wacore/src/iq/privacy.rs b/wacore/src/iq/privacy.rs new file mode 100644 index 000000000..8a8ee67b5 --- /dev/null +++ b/wacore/src/iq/privacy.rs @@ -0,0 +1,300 @@ +//! Privacy settings IQ specification. +//! +//! Fetches the user's privacy settings from the server. +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ... +//! +//! +//! ``` +//! +//! Verified against WhatsApp Web JS (WAWebQueryPrivacy). + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// IQ namespace for privacy settings. +pub const PRIVACY_NAMESPACE: &str = "privacy"; + +/// Privacy setting category name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrivacyCategory { + /// Last seen visibility + Last, + /// Online status visibility + Online, + /// Profile photo visibility + Profile, + /// Status visibility + Status, + /// Group add permissions + GroupAdd, + /// Read receipts + ReadReceipts, + /// Other/unknown category + Other(String), +} + +impl PrivacyCategory { + pub fn as_str(&self) -> &str { + match self { + PrivacyCategory::Last => "last", + PrivacyCategory::Online => "online", + PrivacyCategory::Profile => "profile", + PrivacyCategory::Status => "status", + PrivacyCategory::GroupAdd => "groupadd", + PrivacyCategory::ReadReceipts => "readreceipts", + PrivacyCategory::Other(s) => s.as_str(), + } + } +} + +impl From<&str> for PrivacyCategory { + fn from(s: &str) -> Self { + match s { + "last" => PrivacyCategory::Last, + "online" => PrivacyCategory::Online, + "profile" => PrivacyCategory::Profile, + "status" => PrivacyCategory::Status, + "groupadd" => PrivacyCategory::GroupAdd, + "readreceipts" => PrivacyCategory::ReadReceipts, + other => PrivacyCategory::Other(other.to_string()), + } + } +} + +/// Privacy setting value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrivacyValue { + /// Visible to everyone + All, + /// Visible only to contacts + Contacts, + /// Not visible to anyone + None, + /// Visible to contacts except specific list + ContactBlacklist, + /// Match their settings (for online/last) + MatchLastSeen, + /// Other/unknown value + Other(String), +} + +impl PrivacyValue { + pub fn as_str(&self) -> &str { + match self { + PrivacyValue::All => "all", + PrivacyValue::Contacts => "contacts", + PrivacyValue::None => "none", + PrivacyValue::ContactBlacklist => "contact_blacklist", + PrivacyValue::MatchLastSeen => "match_last_seen", + PrivacyValue::Other(s) => s.as_str(), + } + } +} + +impl From<&str> for PrivacyValue { + fn from(s: &str) -> Self { + match s { + "all" => PrivacyValue::All, + "contacts" => PrivacyValue::Contacts, + "none" => PrivacyValue::None, + "contact_blacklist" => PrivacyValue::ContactBlacklist, + "match_last_seen" => PrivacyValue::MatchLastSeen, + other => PrivacyValue::Other(other.to_string()), + } + } +} + +/// A single privacy setting. +#[derive(Debug, Clone)] +pub struct PrivacySetting { + /// The category name (e.g., "last", "profile", etc.) + pub category: PrivacyCategory, + /// The privacy value (e.g., "all", "contacts", "none") + pub value: PrivacyValue, +} + +/// Response from privacy settings query. +#[derive(Debug, Clone, Default)] +pub struct PrivacySettingsResponse { + /// The list of privacy settings. + pub settings: Vec, +} + +impl PrivacySettingsResponse { + /// Get a privacy setting by category. + pub fn get(&self, category: &PrivacyCategory) -> Option<&PrivacySetting> { + self.settings.iter().find(|s| &s.category == category) + } + + /// Get the value for a category. + pub fn get_value(&self, category: &PrivacyCategory) -> Option<&PrivacyValue> { + self.get(category).map(|s| &s.value) + } +} + +/// Fetches privacy settings from the server. +#[derive(Debug, Clone, Default)] +pub struct PrivacySettingsSpec; + +impl PrivacySettingsSpec { + /// Create a new privacy settings spec. + pub fn new() -> Self { + Self + } +} + +impl IqSpec for PrivacySettingsSpec { + type Response = PrivacySettingsResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get( + PRIVACY_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![NodeBuilder::new("privacy").build()])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + use crate::iq::node::{optional_attr, required_child}; + + let privacy_node = required_child(response, "privacy")?; + + let mut settings = Vec::new(); + for child in privacy_node.get_children_by_tag("category") { + let name = optional_attr(child, "name") + .ok_or_else(|| anyhow::anyhow!("missing name in category"))?; + let value = optional_attr(child, "value") + .ok_or_else(|| anyhow::anyhow!("missing value in category"))?; + + settings.push(PrivacySetting { + category: PrivacyCategory::from(name), + value: PrivacyValue::from(value), + }); + } + + Ok(PrivacySettingsResponse { settings }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_privacy_settings_spec_build_iq() { + let spec = PrivacySettingsSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, PRIVACY_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "privacy"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_privacy_settings_spec_parse_response() { + let spec = PrivacySettingsSpec::new(); + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("privacy") + .children([ + NodeBuilder::new("category") + .attr("name", "last") + .attr("value", "all") + .build(), + NodeBuilder::new("category") + .attr("name", "profile") + .attr("value", "contacts") + .build(), + NodeBuilder::new("category") + .attr("name", "status") + .attr("value", "none") + .build(), + ]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.settings.len(), 3); + + assert_eq!(result.settings[0].category, PrivacyCategory::Last); + assert_eq!(result.settings[0].value, PrivacyValue::All); + + assert_eq!(result.settings[1].category, PrivacyCategory::Profile); + assert_eq!(result.settings[1].value, PrivacyValue::Contacts); + + assert_eq!(result.settings[2].category, PrivacyCategory::Status); + assert_eq!(result.settings[2].value, PrivacyValue::None); + } + + #[test] + fn test_privacy_settings_response_get() { + let response = PrivacySettingsResponse { + settings: vec![ + PrivacySetting { + category: PrivacyCategory::Last, + value: PrivacyValue::All, + }, + PrivacySetting { + category: PrivacyCategory::Profile, + value: PrivacyValue::Contacts, + }, + ], + }; + + assert_eq!( + response.get_value(&PrivacyCategory::Last), + Some(&PrivacyValue::All) + ); + assert_eq!( + response.get_value(&PrivacyCategory::Profile), + Some(&PrivacyValue::Contacts) + ); + assert_eq!(response.get_value(&PrivacyCategory::Online), None); + } + + #[test] + fn test_privacy_category_from_str() { + assert_eq!(PrivacyCategory::from("last"), PrivacyCategory::Last); + assert_eq!(PrivacyCategory::from("online"), PrivacyCategory::Online); + assert_eq!( + PrivacyCategory::from("unknown"), + PrivacyCategory::Other("unknown".to_string()) + ); + } + + #[test] + fn test_privacy_value_from_str() { + assert_eq!(PrivacyValue::from("all"), PrivacyValue::All); + assert_eq!(PrivacyValue::from("contacts"), PrivacyValue::Contacts); + assert_eq!(PrivacyValue::from("none"), PrivacyValue::None); + assert_eq!( + PrivacyValue::from("unknown"), + PrivacyValue::Other("unknown".to_string()) + ); + } +} diff --git a/wacore/src/iq/props.rs b/wacore/src/iq/props.rs new file mode 100644 index 000000000..d99bde3d6 --- /dev/null +++ b/wacore/src/iq/props.rs @@ -0,0 +1,263 @@ +//! A/B Props (experiment config) IQ specification. +//! +//! Fetches server-side A/B testing properties and experiment configurations. +//! +//! ## Wire Format +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ... +//! +//! +//! ``` +//! +//! Verified against WhatsApp Web JS (WASmaxOutAbPropsGetExperimentConfigRequest). + +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// IQ namespace for A/B props. +pub const PROPS_NAMESPACE: &str = "abt"; + +/// Protocol version for props requests. +pub const PROPS_PROTOCOL_VERSION: &str = "1"; + +/// A/B property returned from the server. +#[derive(Debug, Clone)] +pub struct AbProp { + /// The config code (property identifier). + pub config_code: u32, + /// The config value. + pub config_value: String, + /// Optional experiment exposure key. + pub config_expo_key: Option, +} + +/// Response from props query. +#[derive(Debug, Clone, Default)] +pub struct PropsResponse { + /// A/B key for this configuration set. + pub ab_key: Option, + /// Hash of the current configuration. + pub hash: Option, + /// Refresh interval in seconds. + pub refresh: Option, + /// Refresh ID for delta updates. + pub refresh_id: Option, + /// Whether this is a delta update. + pub delta_update: bool, + /// The properties. + pub props: Vec, +} + +/// Fetches A/B testing properties from the server. +#[derive(Debug, Clone, Default)] +pub struct PropsSpec { + /// Optional hash from previous props fetch (for delta updates). + pub hash: Option, + /// Optional refresh ID (for emergency push updates). + pub refresh_id: Option, +} + +impl PropsSpec { + /// Create a new props spec without hash or refresh_id. + pub fn new() -> Self { + Self::default() + } + + /// Create a props spec with a hash for delta updates. + pub fn with_hash(hash: impl Into) -> Self { + Self { + hash: Some(hash.into()), + refresh_id: None, + } + } + + /// Create a props spec with a refresh_id for emergency push responses. + pub fn with_refresh_id(refresh_id: u32) -> Self { + Self { + hash: None, + refresh_id: Some(refresh_id), + } + } +} + +impl IqSpec for PropsSpec { + type Response = PropsResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let mut builder = NodeBuilder::new("props").attr("protocol", PROPS_PROTOCOL_VERSION); + + if let Some(ref hash) = self.hash { + builder = builder.attr("hash", hash.as_str()); + } + + if let Some(refresh_id) = self.refresh_id { + builder = builder.attr("refresh_id", refresh_id.to_string()); + } + + InfoQuery::get( + PROPS_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![builder.build()])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + use crate::iq::node::{optional_attr, required_child}; + + // Find the props child node + let props_node = required_child(response, "props")?; + + let ab_key = optional_attr(props_node, "ab_key").map(str::to_string); + let hash = optional_attr(props_node, "hash").map(str::to_string); + let refresh = optional_attr(props_node, "refresh").and_then(|s| s.parse().ok()); + let refresh_id = optional_attr(props_node, "refresh_id").and_then(|s| s.parse().ok()); + let delta_update = optional_attr(props_node, "delta_update") + .map(|s| s == "true") + .unwrap_or(false); + + // Parse individual prop children + let mut props = Vec::new(); + for child in props_node.get_children_by_tag("prop") { + let config_code: u32 = optional_attr(child, "config_code") + .ok_or_else(|| anyhow::anyhow!("missing config_code in prop"))? + .parse()?; + let config_value = optional_attr(child, "config_value") + .ok_or_else(|| anyhow::anyhow!("missing config_value in prop"))? + .to_string(); + let config_expo_key = optional_attr(child, "config_expo_key").and_then(|s| s.parse().ok()); + + props.push(AbProp { + config_code, + config_value, + config_expo_key, + }); + } + + Ok(PropsResponse { + ab_key, + hash, + refresh, + refresh_id, + delta_update, + props, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_props_spec_build_iq_no_params() { + let spec = PropsSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, PROPS_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "props"); + assert_eq!(nodes[0].attrs.get("protocol"), Some(&"1".to_string())); + assert!(nodes[0].attrs.get("hash").is_none()); + assert!(nodes[0].attrs.get("refresh_id").is_none()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_props_spec_build_iq_with_hash() { + let spec = PropsSpec::with_hash("abc123"); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].attrs.get("hash"), Some(&"abc123".to_string())); + assert!(nodes[0].attrs.get("refresh_id").is_none()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_props_spec_build_iq_with_refresh_id() { + let spec = PropsSpec::with_refresh_id(42); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert!(nodes[0].attrs.get("hash").is_none()); + assert_eq!(nodes[0].attrs.get("refresh_id"), Some(&"42".to_string())); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_props_spec_parse_response() { + let spec = PropsSpec::new(); + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("props") + .attr("protocol", "1") + .attr("ab_key", "test_key") + .attr("hash", "abcdef") + .attr("refresh", "3600") + .attr("refresh_id", "123") + .children([ + NodeBuilder::new("prop") + .attr("config_code", "100") + .attr("config_value", "enabled") + .build(), + NodeBuilder::new("prop") + .attr("config_code", "200") + .attr("config_value", "disabled") + .attr("config_expo_key", "5") + .build(), + ]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.ab_key, Some("test_key".to_string())); + assert_eq!(result.hash, Some("abcdef".to_string())); + assert_eq!(result.refresh, Some(3600)); + assert_eq!(result.refresh_id, Some(123)); + assert!(!result.delta_update); + assert_eq!(result.props.len(), 2); + assert_eq!(result.props[0].config_code, 100); + assert_eq!(result.props[0].config_value, "enabled"); + assert!(result.props[0].config_expo_key.is_none()); + assert_eq!(result.props[1].config_code, 200); + assert_eq!(result.props[1].config_value, "disabled"); + assert_eq!(result.props[1].config_expo_key, Some(5)); + } + + #[test] + fn test_props_spec_parse_response_delta_update() { + let spec = PropsSpec::new(); + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("props") + .attr("protocol", "1") + .attr("delta_update", "true") + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.delta_update); + } +} From a693d68ffd0eec16c7a6b0930935239dd98f9618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 14:17:18 -0300 Subject: [PATCH 10/18] feat: Add DigestKeyBundle and PreKeyUpload IqSpecs --- src/client.rs | 11 +- src/prekeys.rs | 28 ++--- wacore/src/iq/prekeys.rs | 245 +++++++++++++++++++++++++++++++++++++++ wacore/src/iq/privacy.rs | 4 +- wacore/src/iq/props.rs | 3 +- 5 files changed, 261 insertions(+), 30 deletions(-) diff --git a/src/client.rs b/src/client.rs index 55a383791..5910a47ba 100644 --- a/src/client.rs +++ b/src/client.rs @@ -792,18 +792,11 @@ impl Client { } pub async fn send_digest_key_bundle(&self) -> Result<(), crate::request::IqError> { - use crate::request::InfoQuery; + use wacore::iq::prekeys::DigestKeyBundleSpec; debug!(target: "Client", "Sending digest key bundle..."); - let digest_node = NodeBuilder::new("digest").build(); - let iq = InfoQuery::get( - "encrypt", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(vec![digest_node])), - ); - - self.send_iq(iq).await.map(|_| ()) + self.execute(DigestKeyBundleSpec::new()).await.map(|_| ()) } pub(crate) async fn handle_success(self: &Arc, node: &wacore_binary::node::Node) { diff --git a/src/prekeys.rs b/src/prekeys.rs index 7dadee97a..0b72e19d4 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -3,13 +3,11 @@ //! Protocol types are defined in `wacore::iq::prekeys`. use crate::client::Client; -use crate::jid_utils::server_jid; -use crate::request::InfoQuery; use anyhow; use log; use rand::TryRngCore; use rand_core::OsRng; -use wacore::iq::prekeys::{PreKeyCountSpec, PreKeyFetchSpec}; +use wacore::iq::prekeys::{PreKeyCountSpec, PreKeyFetchSpec, PreKeyUploadSpec}; use wacore::libsignal::protocol::{KeyPair, PreKeyBundle}; use wacore::libsignal::store::record_helpers::new_pre_key_record; use wacore_binary::jid::Jid; @@ -137,13 +135,13 @@ impl Client { return Ok(()); } - // Step 3: Build upload request nodes using the centralized utility - let mut pre_key_pairs = Vec::new(); - for (_id, key_pair) in &key_pairs_to_upload { - pre_key_pairs.push((*_id, key_pair.public_key.public_key_bytes().to_vec())); - } + // Step 3: Build upload request using type-safe IqSpec + let pre_key_pairs: Vec<(u32, Vec)> = key_pairs_to_upload + .iter() + .map(|(id, key_pair)| (*id, key_pair.public_key.public_key_bytes().to_vec())) + .collect(); - let iq_content = PreKeyUtils::build_upload_prekeys_request( + let spec = PreKeyUploadSpec::new( device_snapshot.registration_id, device_snapshot .identity_key @@ -157,19 +155,11 @@ impl Client { .public_key_bytes() .to_vec(), device_snapshot.signed_pre_key_signature.to_vec(), - &pre_key_pairs, - ); - - let iq = InfoQuery::set( - "encrypt", - server_jid(), - Some(wacore_binary::node::NodeContent::Nodes(iq_content)), + pre_key_pairs, ); // Step 4: Send IQ to upload pre-keys - if let Err(e) = self.send_iq(iq).await { - return Err(e.into()); - } + self.execute(spec).await?; // Step 5: Store the new pre-keys using existing backend interface for (id, record) in keys_to_upload { diff --git a/wacore/src/iq/prekeys.rs b/wacore/src/iq/prekeys.rs index 2ae38ac76..87f547e14 100644 --- a/wacore/src/iq/prekeys.rs +++ b/wacore/src/iq/prekeys.rs @@ -129,6 +129,152 @@ impl IqSpec for PreKeyFetchSpec { } } +/// Digest Key Bundle Wire Format +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// [binary hash of server-side key bundle] +/// +/// ``` +/// +/// Used to validate that the server-side key bundle matches local keys. +/// If the hash doesn't match, prekeys need to be re-uploaded. +/// +/// Verified against WhatsApp Web JS (WAWebDigestKeyJob). +#[derive(Debug, Clone, Default)] +pub struct DigestKeyBundleSpec; + +impl DigestKeyBundleSpec { + pub fn new() -> Self { + Self + } +} + +/// Response from digest key bundle query. +#[derive(Debug, Clone)] +pub struct DigestKeyBundleResponse { + /// The digest hash bytes from the server (20 bytes SHA-1 hash). + pub digest: Option>, +} + +impl IqSpec for DigestKeyBundleSpec { + type Response = DigestKeyBundleResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let digest_node = NodeBuilder::new("digest").build(); + + InfoQuery::get( + "encrypt", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![digest_node])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let digest_node = response.get_optional_child("digest"); + + let digest = digest_node.and_then(|node| { + node.content.as_ref().and_then(|content| match content { + NodeContent::Bytes(bytes) => Some(bytes.clone()), + _ => None, + }) + }); + + Ok(DigestKeyBundleResponse { digest }) + } +} + +/// Pre-Key Upload Wire Format +/// ```xml +/// +/// +/// [4-byte BE registration ID] +/// [1-byte: 5 for Signal protocol] +/// [32-byte identity public key] +/// +/// [3-byte BE key ID][32-byte public key] +/// ... +/// +/// +/// [3-byte BE signed pre-key ID] +/// [32-byte signed pre-key public] +/// [64-byte signature] +/// +/// +/// +/// +/// +/// ``` +/// +/// Verified against WhatsApp Web JS (WAWebUploadPreKeysJob). +#[derive(Debug, Clone)] +pub struct PreKeyUploadSpec { + /// 4-byte registration ID + pub registration_id: u32, + /// 32-byte identity public key + pub identity_key_bytes: Vec, + /// Signed pre-key ID (uses lower 3 bytes) + pub signed_pre_key_id: u32, + /// 32-byte signed pre-key public + pub signed_pre_key_public_bytes: Vec, + /// 64-byte signature + pub signed_pre_key_signature: Vec, + /// Pre-keys to upload: (id, 32-byte public key) + pub pre_keys: Vec<(u32, Vec)>, +} + +impl PreKeyUploadSpec { + /// Create a new pre-key upload spec. + pub fn new( + registration_id: u32, + identity_key_bytes: Vec, + signed_pre_key_id: u32, + signed_pre_key_public_bytes: Vec, + signed_pre_key_signature: Vec, + pre_keys: Vec<(u32, Vec)>, + ) -> Self { + Self { + registration_id, + identity_key_bytes, + signed_pre_key_id, + signed_pre_key_public_bytes, + signed_pre_key_signature, + pre_keys, + } + } +} + +impl IqSpec for PreKeyUploadSpec { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + let content = PreKeyUtils::build_upload_prekeys_request( + self.registration_id, + self.identity_key_bytes.clone(), + self.signed_pre_key_id, + self.signed_pre_key_public_bytes.clone(), + self.signed_pre_key_signature.clone(), + &self.pre_keys, + ); + + InfoQuery::set( + "encrypt", + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(content)), + ) + } + + fn parse_response(&self, _response: &Node) -> Result { + // Pre-key upload just needs a successful response + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -202,4 +348,103 @@ mod tests { assert_eq!(spec.reason, Some("retry".to_string())); } + + #[test] + fn test_digest_key_bundle_spec_build_iq() { + let spec = DigestKeyBundleSpec::new(); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "encrypt"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Get); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "digest"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_digest_key_bundle_spec_parse_response() { + let spec = DigestKeyBundleSpec::new(); + let digest_bytes = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("digest") + .bytes(digest_bytes.clone()) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.digest, Some(digest_bytes)); + } + + #[test] + fn test_digest_key_bundle_spec_parse_response_empty() { + let spec = DigestKeyBundleSpec::new(); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("digest").build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.digest, None); + } + + #[test] + fn test_prekey_upload_spec_build_iq() { + let spec = PreKeyUploadSpec::new( + 12345, // registration_id + vec![1u8; 32], // identity_key_bytes + 1, // signed_pre_key_id + vec![2u8; 32], // signed_pre_key_public_bytes + vec![3u8; 64], // signed_pre_key_signature + vec![(100, vec![4u8; 32]), (101, vec![5u8; 32])], // pre_keys + ); + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, "encrypt"); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + // Expected: registration, type, identity, list, skey + assert_eq!(nodes.len(), 5); + assert_eq!(nodes[0].tag, "registration"); + assert_eq!(nodes[1].tag, "type"); + assert_eq!(nodes[2].tag, "identity"); + assert_eq!(nodes[3].tag, "list"); + assert_eq!(nodes[4].tag, "skey"); + + // Check that list has 2 pre-keys + if let Some(list_children) = nodes[3].children() { + assert_eq!(list_children.len(), 2); + assert_eq!(list_children[0].tag, "key"); + assert_eq!(list_children[1].tag, "key"); + } else { + panic!("Expected list to have children"); + } + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_prekey_upload_spec_parse_response() { + let spec = PreKeyUploadSpec::new( + 12345, + vec![1u8; 32], + 1, + vec![2u8; 32], + vec![3u8; 64], + vec![(100, vec![4u8; 32])], + ); + + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response); + assert!(result.is_ok()); + } } diff --git a/wacore/src/iq/privacy.rs b/wacore/src/iq/privacy.rs index 8a8ee67b5..43b339151 100644 --- a/wacore/src/iq/privacy.rs +++ b/wacore/src/iq/privacy.rs @@ -169,7 +169,9 @@ impl IqSpec for PrivacySettingsSpec { InfoQuery::get( PRIVACY_NAMESPACE, Jid::new("", SERVER_JID), - Some(NodeContent::Nodes(vec![NodeBuilder::new("privacy").build()])), + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("privacy").build(), + ])), ) } diff --git a/wacore/src/iq/props.rs b/wacore/src/iq/props.rs index d99bde3d6..c0ea495d5 100644 --- a/wacore/src/iq/props.rs +++ b/wacore/src/iq/props.rs @@ -137,7 +137,8 @@ impl IqSpec for PropsSpec { let config_value = optional_attr(child, "config_value") .ok_or_else(|| anyhow::anyhow!("missing config_value in prop"))? .to_string(); - let config_expo_key = optional_attr(child, "config_expo_key").and_then(|s| s.parse().ok()); + let config_expo_key = + optional_attr(child, "config_expo_key").and_then(|s| s.parse().ok()); props.push(AbProp { config_code, From a6973b3e9c21fca812fecbb0c18c7f87187d6d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 14:33:12 -0300 Subject: [PATCH 11/18] fix: match exactly whatsapp web presence sending --- src/client.rs | 46 ++++++---- src/features/presence.rs | 189 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 210 insertions(+), 25 deletions(-) diff --git a/src/client.rs b/src/client.rs index 5910a47ba..2407e549c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -858,19 +858,15 @@ impl Client { info!(target: "Client", "Starting post-login initialization sequence (gen={})...", task_generation); - let mut force_initial_sync = false; + // Check if we need initial app state sync (empty pushname indicates fresh pairing + // where pushname will come from app state sync's setting_pushName mutation) let device_snapshot = client_clone.persistence_manager.get_device_snapshot().await; - if device_snapshot.push_name.is_empty() { - const DEFAULT_PUSH_NAME: &str = "WhatsApp Rust"; - warn!( + let needs_pushname_from_sync = device_snapshot.push_name.is_empty(); + if needs_pushname_from_sync { + debug!( target: "Client", - "Push name is empty! Setting default to '{DEFAULT_PUSH_NAME}' to allow presence." + "Push name is empty - will be set from app state sync (setting_pushName)" ); - client_clone - .persistence_manager - .process_command(DeviceCommand::SetPushName(DEFAULT_PUSH_NAME.to_string())) - .await; - force_initial_sync = true; } // Check connection before network operations. @@ -944,15 +940,19 @@ impl Client { return; } - // Send presence (like WhatsApp Web's sendPresenceAvailable after passive tasks) - if let Err(e) = client_clone.presence().set_available().await { - warn!("Failed to send initial presence: {e:?}"); + // Send presence if we have a pushname (like WhatsApp Web's sendPresenceAvailable). + // If pushname is empty, we'll send presence after app state sync provides it. + let device_snapshot = client_clone.persistence_manager.get_device_snapshot().await; + if !device_snapshot.push_name.is_empty() { + if let Err(e) = client_clone.presence().set_available().await { + warn!("Failed to send initial presence: {e:?}"); + } else { + info!("Initial presence sent successfully."); + } } else { - info!("Initial presence sent successfully."); + debug!(target: "Client", "Deferring presence until pushname is available from app state sync"); } - // === End of Passive Tasks === - check_generation!(); // Background initialization queries (can run in parallel, non-blocking) @@ -1006,10 +1006,10 @@ impl Client { check_generation!(); let flag_set = client_clone.needs_initial_full_sync.load(Ordering::Relaxed); - if flag_set || force_initial_sync { + if flag_set || needs_pushname_from_sync { info!( target: "Client/AppState", - "Starting Initial App State Sync (flag_set={flag_set}, force={force_initial_sync})" + "Starting Initial App State Sync (flag_set={flag_set}, needs_pushname={needs_pushname_from_sync})" ); if !client_clone @@ -1327,10 +1327,18 @@ impl Client { bus.dispatch(&Event::SelfPushNameUpdated( crate::types::events::SelfPushNameUpdated { from_server: true, - old_name: old, + old_name: old.clone(), new_name: new_name.clone(), }, )); + + // WhatsApp Web sends presence immediately when receiving pushname from + if old.is_empty() && !new_name.is_empty() { + info!(target: "Client/AppState", "Sending presence after receiving initial pushname from app state sync"); + if let Err(e) = self.presence().set_available().await { + warn!(target: "Client/AppState", "Failed to send presence after pushname sync: {e:?}"); + } + } } else { debug!(target: "Client/AppState", "Push name mutation received but name unchanged: '{}'", new_name); } diff --git a/src/features/presence.rs b/src/features/presence.rs index 54a7966a2..c7ca7761a 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -1,5 +1,3 @@ -//! Presence (online status) feature. - use crate::client::Client; use log::{debug, info, warn}; use wacore::StringEnum; @@ -91,10 +89,189 @@ impl Client { #[cfg(test)] mod tests { use super::*; + use crate::bot::Bot; + use crate::http::{HttpClient, HttpRequest, HttpResponse}; + use crate::store::SqliteStore; + use crate::store::commands::DeviceCommand; + use anyhow::Result; + use std::sync::Arc; + use wacore::store::traits::Backend; + use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; + + // Mock HTTP client for testing + #[derive(Debug, Clone)] + struct MockHttpClient; + + #[async_trait::async_trait] + impl HttpClient for MockHttpClient { + async fn execute(&self, _request: HttpRequest) -> Result { + Ok(HttpResponse { + status_code: 200, + body: br#"self.__swData=JSON.parse(/*BTDS*/"{\"dynamic_data\":{\"SiteData\":{\"server_revision\":1026131876,\"client_revision\":1026131876}}}");"#.to_vec(), + }) + } + } + + async fn create_test_backend() -> Arc { + let temp_db = format!( + "file:memdb_presence_{}?mode=memory&cache=shared", + uuid::Uuid::new_v4() + ); + Arc::new( + SqliteStore::new(&temp_db) + .await + .expect("Failed to create test SqliteStore"), + ) as Arc + } + + /// Integration test: Presence returns error when pushname is empty + /// + /// This verifies the WhatsApp Web behavior where presence is deferred + /// until pushname is available (either from storage or app state sync). + #[tokio::test] + async fn test_presence_rejected_when_pushname_empty() { + // Create a bot with an empty device (no pushname set) + let backend = create_test_backend().await; + let transport = TokioWebSocketTransportFactory::new(); + + let bot = Bot::builder() + .with_backend(backend) + .with_transport_factory(transport) + .with_http_client(MockHttpClient) + .build() + .await + .expect("Failed to build bot"); + + let client = bot.client(); + + // Verify pushname is empty initially + let snapshot = client.persistence_manager().get_device_snapshot().await; + assert!( + snapshot.push_name.is_empty(), + "Pushname should be empty on fresh device" + ); + + // Attempt to set presence - should fail with empty pushname + let result: Result<(), anyhow::Error> = + client.presence().set(PresenceStatus::Available).await; + + assert!( + result.is_err(), + "Presence should fail when pushname is empty" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Cannot send presence without a push name set"), + "Error message should indicate missing pushname: {}", + err_msg + ); + } + + /// Integration test: Presence succeeds after pushname is set + /// + /// This simulates the flow where pushname arrives from app state sync + /// (setting_pushName mutation) and presence can then be sent. + #[tokio::test] + async fn test_presence_succeeds_after_pushname_set() { + let backend = create_test_backend().await; + let transport = TokioWebSocketTransportFactory::new(); + + let bot = Bot::builder() + .with_backend(backend) + .with_transport_factory(transport) + .with_http_client(MockHttpClient) + .build() + .await + .expect("Failed to build bot"); + + let client = bot.client(); - #[test] - fn test_presence_status_string_enum() { - assert_eq!(PresenceStatus::Available.as_str(), "available"); - assert_eq!(PresenceStatus::Unavailable.to_string(), "unavailable"); + // Set pushname via DeviceCommand (simulates receiving setting_pushName from app state sync) + client + .persistence_manager() + .process_command(DeviceCommand::SetPushName("Test User".to_string())) + .await; + + // Verify pushname was set + let snapshot = client.persistence_manager().get_device_snapshot().await; + assert_eq!(snapshot.push_name, "Test User"); + + // Now presence would succeed (but fails at send_node since we're not connected) + // The validation passes, so we check the error is about connection, not pushname + let result: Result<(), anyhow::Error> = + client.presence().set(PresenceStatus::Available).await; + + // The error should be about not being connected, not about missing pushname + if let Err(e) = result { + let err_msg = e.to_string(); + assert!( + !err_msg.contains("Cannot send presence without a push name set"), + "Should not fail due to missing pushname after it was set: {}", + err_msg + ); + // Expected: connection-related error since we're not actually connected + assert!( + err_msg.contains("not connected") || err_msg.contains("NotConnected"), + "Expected connection error, got: {}", + err_msg + ); + } + // If somehow it succeeds (unlikely without connection), that's also fine + } + + /// Integration test: Verify pushname flow matches WhatsApp Web + /// + /// WhatsApp Web flow (WAWebPushNameSync.js): + /// 1. Fresh pairing: pushname is empty + /// 2. App state sync sends setting_pushName mutation + /// 3. Presence is sent immediately after receiving pushname + #[tokio::test] + async fn test_pushname_presence_flow_matches_whatsapp_web() { + let backend = create_test_backend().await; + let transport = TokioWebSocketTransportFactory::new(); + + let bot = Bot::builder() + .with_backend(backend) + .with_transport_factory(transport) + .with_http_client(MockHttpClient) + .build() + .await + .expect("Failed to build bot"); + + let client = bot.client(); + + // Step 1: Fresh device has empty pushname + let snapshot = client.persistence_manager().get_device_snapshot().await; + assert!( + snapshot.push_name.is_empty(), + "Fresh device should have empty pushname" + ); + + // Step 2: Presence fails with empty pushname (matches WhatsApp Web deferring presence) + let result: Result<(), anyhow::Error> = + client.presence().set(PresenceStatus::Available).await; + assert!( + result.is_err(), + "Presence should be deferred when pushname is empty" + ); + + // Step 3: Pushname arrives (simulates setting_pushName from app state sync) + client + .persistence_manager() + .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string())) + .await; + + // Step 4: Now presence validation passes (actual send fails due to no connection) + let result: Result<(), anyhow::Error> = + client.presence().set(PresenceStatus::Available).await; + + // Should NOT be a pushname error + if let Err(e) = result { + assert!( + !e.to_string().contains("push name"), + "After setting pushname, error should be connection-related, not pushname: {}", + e + ); + } } } From 7961745a20357d5e0aad03340d105f64a39f695b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 14:50:43 -0300 Subject: [PATCH 12/18] fix: coderabiit comments suggestions --- src/client.rs | 4 ++-- src/features/blocking.rs | 5 ++-- src/message.rs | 50 ++++++++++++++++++++++++++++++++++------ wacore/derive/src/lib.rs | 40 ++++++++++++++++---------------- wacore/src/iq/dirty.rs | 34 +++++++++++++++++++-------- 5 files changed, 91 insertions(+), 42 deletions(-) diff --git a/src/client.rs b/src/client.rs index 2407e549c..48074c0bc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -770,8 +770,8 @@ impl Client { ) -> Result<(), crate::request::IqError> { use wacore::iq::dirty::CleanDirtyBitsSpec; - self.execute(CleanDirtyBitsSpec::single(type_, timestamp)) - .await + let spec = CleanDirtyBitsSpec::single(type_, timestamp)?; + self.execute(spec).await } pub async fn fetch_props(&self) -> Result<(), crate::request::IqError> { diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 9c39057ba..7e1965f87 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -5,7 +5,6 @@ use crate::client::Client; use crate::request::IqError; -use anyhow::Result; use log::debug; pub use wacore::iq::blocklist::BlocklistEntry; use wacore::iq::blocklist::{GetBlocklistSpec, UpdateBlocklistSpec}; @@ -40,7 +39,7 @@ impl<'a> Blocking<'a> { } /// Get the full blocklist. - pub async fn get_blocklist(&self) -> Result> { + pub async fn get_blocklist(&self) -> anyhow::Result> { debug!(target: "Blocking", "Fetching blocklist..."); let entries = self.client.execute(GetBlocklistSpec).await?; debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len()); @@ -51,7 +50,7 @@ impl<'a> Blocking<'a> { /// /// Compares only the user part of the JID, ignoring device ID, /// since blocking applies to the entire user account, not individual devices. - pub async fn is_blocked(&self, jid: &Jid) -> Result { + pub async fn is_blocked(&self, jid: &Jid) -> anyhow::Result { let blocklist = self.get_blocklist().await?; Ok(blocklist.iter().any(|e| e.jid.user == jid.user)) } diff --git a/src/message.rs b/src/message.rs index 0c86f8df1..db7b98525 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1138,13 +1138,7 @@ impl Client { .map(|s| s.to_string()) .unwrap_or_default(); - let id = match attrs.optional_string("id") { - Some(id) => id.to_string(), - None => { - log::warn!("Message missing 'id' attribute"); - String::new() - } - }; + let id = attrs.required_string("id")?.to_string(); Ok(MessageInfo { source, @@ -4001,4 +3995,46 @@ mod tests { ); } } + + /// Test: parse_message_info returns error when message "id" attribute is missing + /// + /// Missing message IDs would cause silent collisions in caches/keys, so this + /// must be a hard error rather than defaulting to an empty string. + #[tokio::test] + async fn test_parse_message_info_missing_id_returns_error() { + // 1. Setup + let backend = Arc::new( + SqliteStore::new("file:memdb_missing_id_test?mode=memory&cache=shared") + .await + .expect("Failed to create test backend"), + ); + let pm = Arc::new( + PersistenceManager::new(backend) + .await + .expect("test backend should initialize"), + ); + let (client, _sync_rx) = Client::new(pm, mock_transport(), mock_http_client(), None).await; + + // 2. Create a message node WITHOUT the "id" attribute + let node = NodeBuilder::new("message") + .attr("from", "15551234567@s.whatsapp.net") + .attr("t", "1759295366") + .attr("type", "text") + .build(); + + // 3. Run the function under test - should return an error + let result = client.parse_message_info(&node).await; + + // 4. Assert that it returns an error about missing id + assert!( + result.is_err(), + "parse_message_info should fail when 'id' is missing" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("id"), + "Error message should mention missing 'id' attribute: {}", + err_msg + ); + } } diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 0664fbc97..31e019139 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -130,7 +130,7 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } else { quote! { #field_ident: node.attrs().optional_string(#attr_name) - .ok_or_else(|| anyhow::anyhow!("missing required attribute '{}'", #attr_name))? + .ok_or_else(|| ::anyhow::anyhow!("missing required attribute '{}'", #attr_name))? .to_string() } } @@ -151,20 +151,20 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { .collect(); let expanded = quote! { - impl crate::protocol::ProtocolNode for #name { + impl ::wacore::protocol::ProtocolNode for #name { fn tag(&self) -> &'static str { #tag } - fn into_node(self) -> wacore_binary::node::Node { - wacore_binary::builder::NodeBuilder::new(#tag) + fn into_node(self) -> ::wacore_binary::node::Node { + ::wacore_binary::builder::NodeBuilder::new(#tag) #(#attr_setters)* .build() } - fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + fn try_from_node(node: &::wacore_binary::node::Node) -> ::anyhow::Result { if node.tag != #tag { - return Err(anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); + return Err(::anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); } Ok(Self { #(#field_parsers),* @@ -172,7 +172,7 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } } - impl Default for #name { + impl ::core::default::Default for #name { fn default() -> Self { Self { #(#default_fields),* @@ -221,24 +221,24 @@ pub fn derive_empty_node(input: TokenStream) -> TokenStream { fn generate_empty_impl(name: &syn::Ident, tag: &str) -> proc_macro2::TokenStream { quote! { - impl crate::protocol::ProtocolNode for #name { + impl ::wacore::protocol::ProtocolNode for #name { fn tag(&self) -> &'static str { #tag } - fn into_node(self) -> wacore_binary::node::Node { - wacore_binary::builder::NodeBuilder::new(#tag).build() + fn into_node(self) -> ::wacore_binary::node::Node { + ::wacore_binary::builder::NodeBuilder::new(#tag).build() } - fn try_from_node(node: &wacore_binary::node::Node) -> anyhow::Result { + fn try_from_node(node: &::wacore_binary::node::Node) -> ::anyhow::Result { if node.tag != #tag { - return Err(anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); + return Err(::anyhow::anyhow!("expected <{}>, got <{}>", #tag, node.tag)); } Ok(Self) } } - impl Default for #name { + impl ::core::default::Default for #name { fn default() -> Self { Self } @@ -432,24 +432,24 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { } } - impl std::fmt::Display for #name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + impl ::core::fmt::Display for #name { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { f.write_str(self.as_str()) } } - impl TryFrom<&str> for #name { - type Error = anyhow::Error; + impl ::core::convert::TryFrom<&str> for #name { + type Error = ::anyhow::Error; - fn try_from(value: &str) -> Result { + fn try_from(value: &str) -> ::core::result::Result { match value { #(#try_from_arms),*, - _ => Err(anyhow::anyhow!("unknown {}: {}", stringify!(#name), value)), + _ => Err(::anyhow::anyhow!("unknown {}: {}", stringify!(#name), value)), } } } - impl Default for #name { + impl ::core::default::Default for #name { fn default() -> Self { #name::#default_variant } diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs index c5dd21efa..aee56da03 100644 --- a/wacore/src/iq/dirty.rs +++ b/wacore/src/iq/dirty.rs @@ -92,17 +92,19 @@ pub struct CleanDirtyBitsSpec { impl CleanDirtyBitsSpec { /// Create a spec to clean a single dirty bit. - pub fn single(dirty_type: &str, timestamp: Option<&str>) -> Self { + /// + /// # Errors + /// Returns an error if `timestamp` is provided but cannot be parsed as `u64`. + pub fn single(dirty_type: &str, timestamp: Option<&str>) -> Result { let bit = if let Some(ts) = timestamp { - if let Ok(ts_num) = ts.parse() { - DirtyBit::with_timestamp(DirtyType::from(dirty_type), ts_num) - } else { - DirtyBit::new(DirtyType::from(dirty_type)) - } + let ts_num: u64 = ts + .parse() + .map_err(|e| anyhow::anyhow!("invalid timestamp '{}': {}", ts, e))?; + DirtyBit::with_timestamp(DirtyType::from(dirty_type), ts_num) } else { DirtyBit::new(DirtyType::from(dirty_type)) }; - Self { bits: vec![bit] } + Ok(Self { bits: vec![bit] }) } /// Create a spec to clean multiple dirty bits. @@ -147,7 +149,7 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_single() { - let spec = CleanDirtyBitsSpec::single("account_sync", None); + let spec = CleanDirtyBitsSpec::single("account_sync", None).unwrap(); let iq = spec.build_iq(); assert_eq!(iq.namespace, DIRTY_NAMESPACE); @@ -168,7 +170,7 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_with_timestamp() { - let spec = CleanDirtyBitsSpec::single("groups", Some("1234567890")); + let spec = CleanDirtyBitsSpec::single("groups", Some("1234567890")).unwrap(); let iq = spec.build_iq(); if let Some(NodeContent::Nodes(nodes)) = &iq.content { @@ -183,6 +185,18 @@ mod tests { } } + #[test] + fn test_clean_dirty_bits_spec_invalid_timestamp() { + let result = CleanDirtyBitsSpec::single("account_sync", Some("not_a_number")); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("invalid timestamp"), + "Error should mention invalid timestamp: {}", + err_msg + ); + } + #[test] fn test_clean_dirty_bits_spec_multiple() { let bits = vec![ @@ -211,7 +225,7 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_parse_response() { - let spec = CleanDirtyBitsSpec::single("account_sync", None); + let spec = CleanDirtyBitsSpec::single("account_sync", None).unwrap(); let response = NodeBuilder::new("iq").attr("type", "result").build(); let result = spec.parse_response(&response); From 0f1831fdafd639d0cb8a3e69f6d2407833ef5070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 15:50:34 -0300 Subject: [PATCH 13/18] chore: remove redundant comments and simplify code --- src/client/sessions.rs | 14 +-- src/features/chatstate.rs | 7 -- src/features/presence.rs | 62 ++++--------- src/handlers/message.rs | 3 - src/message.rs | 184 +++----------------------------------- src/send.rs | 6 +- src/usync.rs | 6 +- wacore/derive/src/lib.rs | 13 +-- wacore/src/iq/dirty.rs | 14 +-- 9 files changed, 38 insertions(+), 271 deletions(-) diff --git a/src/client/sessions.rs b/src/client/sessions.rs index 887d10aae..e9eb62799 100644 --- a/src/client/sessions.rs +++ b/src/client/sessions.rs @@ -61,17 +61,9 @@ impl Client { for jid in resolved_jids { let signal_addr = jid.to_protocol_address(); match device_guard.contains_session(&signal_addr).await { - Ok(true) => { - // Session exists, skip - } - Ok(false) => { - // No session, need to establish one - jids_needing_sessions.push(jid); - } - Err(e) => { - // Storage error - log and skip this JID rather than treating as missing - log::warn!("Failed to check session for {}: {}", jid, e); - } + Ok(true) => {} + Ok(false) => jids_needing_sessions.push(jid), + Err(e) => log::warn!("Failed to check session for {}: {}", jid, e), } } } diff --git a/src/features/chatstate.rs b/src/features/chatstate.rs index 3d2e236a0..8862c7ff6 100644 --- a/src/features/chatstate.rs +++ b/src/features/chatstate.rs @@ -9,13 +9,10 @@ use wacore_binary::jid::Jid; /// Chat state type for typing indicators. #[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] pub enum ChatStateType { - /// User is typing a text message. #[str = "composing"] Composing, - /// User is recording an audio message. #[str = "recording"] Recording, - /// User has stopped typing. #[str = "paused"] Paused, } @@ -42,17 +39,14 @@ impl<'a> Chatstate<'a> { self.client.send_node(node).await } - /// Send "composing" (typing) state. pub async fn send_composing(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Composing).await } - /// Send "recording" (voice message) state. pub async fn send_recording(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Recording).await } - /// Send "paused" (stopped typing) state. pub async fn send_paused(&self, to: &Jid) -> Result<(), crate::client::ClientError> { self.send(to, ChatStateType::Paused).await } @@ -86,7 +80,6 @@ mod tests { #[test] fn test_chat_state_type_string_enum() { - // Verify StringEnum derive works correctly assert_eq!(ChatStateType::Composing.as_str(), "composing"); assert_eq!(ChatStateType::Recording.to_string(), "recording"); assert_eq!( diff --git a/src/features/presence.rs b/src/features/presence.rs index c7ca7761a..930166ef7 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -124,13 +124,9 @@ mod tests { ) as Arc } - /// Integration test: Presence returns error when pushname is empty - /// - /// This verifies the WhatsApp Web behavior where presence is deferred - /// until pushname is available (either from storage or app state sync). + /// Verifies WhatsApp Web behavior: presence deferred until pushname available. #[tokio::test] async fn test_presence_rejected_when_pushname_empty() { - // Create a bot with an empty device (no pushname set) let backend = create_test_backend().await; let transport = TokioWebSocketTransportFactory::new(); @@ -144,14 +140,12 @@ mod tests { let client = bot.client(); - // Verify pushname is empty initially let snapshot = client.persistence_manager().get_device_snapshot().await; assert!( snapshot.push_name.is_empty(), "Pushname should be empty on fresh device" ); - // Attempt to set presence - should fail with empty pushname let result: Result<(), anyhow::Error> = client.presence().set(PresenceStatus::Available).await; @@ -159,18 +153,16 @@ mod tests { result.is_err(), "Presence should fail when pushname is empty" ); - let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("Cannot send presence without a push name set"), - "Error message should indicate missing pushname: {}", - err_msg + result + .unwrap_err() + .to_string() + .contains("Cannot send presence without a push name set"), + "Error should indicate missing pushname" ); } - /// Integration test: Presence succeeds after pushname is set - /// - /// This simulates the flow where pushname arrives from app state sync - /// (setting_pushName mutation) and presence can then be sent. + /// Simulates pushname arriving from app state sync (setting_pushName mutation). #[tokio::test] async fn test_presence_succeeds_after_pushname_set() { let backend = create_test_backend().await; @@ -186,45 +178,34 @@ mod tests { let client = bot.client(); - // Set pushname via DeviceCommand (simulates receiving setting_pushName from app state sync) client .persistence_manager() .process_command(DeviceCommand::SetPushName("Test User".to_string())) .await; - // Verify pushname was set let snapshot = client.persistence_manager().get_device_snapshot().await; assert_eq!(snapshot.push_name, "Test User"); - // Now presence would succeed (but fails at send_node since we're not connected) - // The validation passes, so we check the error is about connection, not pushname + // Validation passes; error should be connection-related, not pushname let result: Result<(), anyhow::Error> = client.presence().set(PresenceStatus::Available).await; - // The error should be about not being connected, not about missing pushname if let Err(e) = result { let err_msg = e.to_string(); assert!( - !err_msg.contains("Cannot send presence without a push name set"), - "Should not fail due to missing pushname after it was set: {}", + !err_msg.contains("push name"), + "Should not fail due to pushname: {}", err_msg ); - // Expected: connection-related error since we're not actually connected assert!( err_msg.contains("not connected") || err_msg.contains("NotConnected"), "Expected connection error, got: {}", err_msg ); } - // If somehow it succeeds (unlikely without connection), that's also fine } - /// Integration test: Verify pushname flow matches WhatsApp Web - /// - /// WhatsApp Web flow (WAWebPushNameSync.js): - /// 1. Fresh pairing: pushname is empty - /// 2. App state sync sends setting_pushName mutation - /// 3. Presence is sent immediately after receiving pushname + /// Matches WAWebPushNameSync.js: fresh pairing -> app state sync -> presence. #[tokio::test] async fn test_pushname_presence_flow_matches_whatsapp_web() { let backend = create_test_backend().await; @@ -240,36 +221,29 @@ mod tests { let client = bot.client(); - // Step 1: Fresh device has empty pushname + // Fresh device has empty pushname let snapshot = client.persistence_manager().get_device_snapshot().await; - assert!( - snapshot.push_name.is_empty(), - "Fresh device should have empty pushname" - ); + assert!(snapshot.push_name.is_empty()); - // Step 2: Presence fails with empty pushname (matches WhatsApp Web deferring presence) + // Presence deferred when pushname empty let result: Result<(), anyhow::Error> = client.presence().set(PresenceStatus::Available).await; - assert!( - result.is_err(), - "Presence should be deferred when pushname is empty" - ); + assert!(result.is_err()); - // Step 3: Pushname arrives (simulates setting_pushName from app state sync) + // Pushname arrives via app state sync client .persistence_manager() .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string())) .await; - // Step 4: Now presence validation passes (actual send fails due to no connection) + // Now presence validation passes let result: Result<(), anyhow::Error> = client.presence().set(PresenceStatus::Available).await; - // Should NOT be a pushname error if let Err(e) = result { assert!( !e.to_string().contains("push name"), - "After setting pushname, error should be connection-related, not pushname: {}", + "Error should be connection-related: {}", e ); } diff --git a/src/handlers/message.rs b/src/handlers/message.rs index b45350d16..b882d8caa 100644 --- a/src/handlers/message.rs +++ b/src/handlers/message.rs @@ -38,9 +38,6 @@ impl StanzaHandler for MessageHandler { } }; - // Node is already Arc-wrapped - no cloning needed! - // This is the key optimization: we pass the same Arc through the system. - // CRITICAL: Acquire the enqueue lock BEFORE getting/creating the queue. // This ensures that messages are enqueued in the exact order they arrive, // even when multiple messages arrive concurrently and the queue needs diff --git a/src/message.rs b/src/message.rs index db7b98525..2e4f50c63 100644 --- a/src/message.rs +++ b/src/message.rs @@ -116,24 +116,21 @@ impl Client { // Max retries reached, don't increment Op::Nop } else { - // Increment the counter Op::Put(current + 1) } } else { - // No entry exists, insert initial count of 1 Op::Put(1_u8) }; std::future::ready(op) }) .await; - // Extract the new count from the result match result { moka::ops::compute::CompResult::Inserted(entry) => Some(entry.into_value()), moka::ops::compute::CompResult::ReplacedWith(entry) => Some(entry.into_value()), moka::ops::compute::CompResult::Unchanged(_) => None, // Max retries reached - moka::ops::compute::CompResult::StillNone(_) => None, // Should not happen - moka::ops::compute::CompResult::Removed(_) => None, // Should not happen + moka::ops::compute::CompResult::StillNone(_) => None, + moka::ops::compute::CompResult::Removed(_) => None, } } @@ -1353,7 +1350,6 @@ mod tests { #[tokio::test] async fn test_parse_message_info_for_status_broadcast() { - // 1. Setup let backend = Arc::new( SqliteStore::new("file:memdb_status_test?mode=memory&cache=shared") .await @@ -1369,7 +1365,6 @@ mod tests { let participant_jid_str = "556899336555:42@s.whatsapp.net"; let status_broadcast_jid_str = "status@broadcast"; - // 2. Create the test node mirroring the logs let node = NodeBuilder::new("message") .attr("from", status_broadcast_jid_str) .attr("id", "8A8CCCC7E6E466D9EE8CA11A967E485A") @@ -1378,13 +1373,11 @@ mod tests { .attr("type", "media") .build(); - // 3. Run the function under test let info = client .parse_message_info(&node) .await .expect("parse_message_info should not fail"); - // 4. Assert the correct behavior let expected_sender: Jid = participant_jid_str .parse() .expect("test JID should be valid"); @@ -1410,7 +1403,6 @@ mod tests { async fn test_process_session_enc_batch_handles_session_not_found_gracefully() { use wacore::libsignal::protocol::{IdentityKeyPair, KeyPair, SignalMessage}; - // 1. Setup let backend = Arc::new( SqliteStore::new("file:memdb_graceful_fail?mode=memory&cache=shared") .await @@ -1435,7 +1427,7 @@ mod tests { ..Default::default() }; - // 2. Create a valid but undecryptable SignalMessage (encrypted with a dummy key) + // Create a valid but undecryptable SignalMessage let dummy_key = [0u8; 32]; let sender_ratchet = KeyPair::generate(&mut rand::rngs::OsRng.unwrap_err()).public_key; let sender_identity_pair = IdentityKeyPair::generate(&mut rand::rngs::OsRng.unwrap_err()); @@ -1458,29 +1450,21 @@ mod tests { .build(); let enc_nodes = vec![&enc_node]; - // 3. Run the function under test - // The function now returns (any_success, any_duplicate, dispatched_undecryptable). - // With a SessionNotFound error, it should return (false, false, true) since it dispatches an event. + // With SessionNotFound, should return (false, false, true) - no success, no dupe, dispatched event let (success, had_duplicates, dispatched) = client .process_session_enc_batch(&enc_nodes, &info, &sender_jid) .await; - // 4. Assert the desired behavior: the function continues gracefully - // The function should return (false, false, true) (no successful decryption, no duplicates, but dispatched event) assert!( !success && !had_duplicates && dispatched, "process_session_enc_batch should return (false, false, true) when SessionNotFound occurs and dispatches event" ); - - // Note: Verifying event dispatch would require adding a test event handler. - // For this test, we're just ensuring the function doesn't panic and returns the correct status. } #[tokio::test] async fn test_handle_encrypted_message_skips_skmsg_after_msg_failure() { use wacore::libsignal::protocol::{IdentityKeyPair, KeyPair, SignalMessage}; - // 1. Setup let backend = Arc::new( SqliteStore::new("file:memdb_skip_skmsg_test?mode=memory&cache=shared") .await @@ -1500,8 +1484,7 @@ mod tests { .parse() .expect("test JID should be valid"); - // 2. Create a message node with both msg and skmsg - // The msg will fail to decrypt (no session), so skmsg should be skipped + // Create msg + skmsg node; msg will fail (no session), so skmsg should be skipped let dummy_key = [0u8; 32]; let sender_ratchet = KeyPair::generate(&mut rand::rngs::OsRng.unwrap_err()).public_key; let sender_identity_pair = IdentityKeyPair::generate(&mut rand::rngs::OsRng.unwrap_err()); @@ -1538,14 +1521,8 @@ mod tests { .build(), ); - // 3. Run the function - // This should NOT panic or cause a retry loop. The skmsg should be skipped. + // Should not panic or retry loop - skmsg is skipped after msg failure client.handle_encrypted_message(message_node).await; - - // 4. Assert - // If we get here without panicking, the test passes. - // The key improvement is that we won't send a retry receipt for the skmsg - // since we detected the msg failure and skipped skmsg processing entirely. } /// Test case for reproducing sender key JID mismatch in LID group messages @@ -1568,8 +1545,6 @@ mod tests { process_sender_key_distribution_message, }; use wacore::libsignal::store::sender_key_name::SenderKeyName; - - // Setup let backend = Arc::new( SqliteStore::new("file:memdb_sender_key_test?mode=memory&cache=shared") .await @@ -1583,8 +1558,6 @@ mod tests { let (_client, _sync_rx) = Client::new(pm.clone(), mock_transport(), mock_http_client(), None).await; - // Simulate own LID: 100000000000001.1:75@lid (note: using device 75 to match real scenario) - // Phone number: 15551234567:75@s.whatsapp.net let own_lid: Jid = "100000000000001.1:75@lid" .parse() .expect("test JID should be valid"); @@ -1595,8 +1568,7 @@ mod tests { .parse() .expect("test JID should be valid"); - // Step 1: Create a real sender key distribution message using LID address - // This mimics what happens in handle_sender_key_distribution_message + // Create SKDM using LID address (mimics handle_sender_key_distribution_message) let lid_protocol_address = own_lid.to_protocol_address(); let lid_sender_key_name = SenderKeyName::new(group_jid.to_string(), lid_protocol_address.to_string()); @@ -1613,7 +1585,6 @@ mod tests { .expect("Failed to create SKDM") }; - // Step 2: Process the SKDM to ensure it's stored properly { let mut device_guard = device_arc.write().await; process_sender_key_distribution_message( @@ -1625,12 +1596,7 @@ mod tests { .expect("Failed to process SKDM with LID address"); } - println!( - "✅ Step 1: Stored sender key under LID address: {}", - lid_protocol_address - ); - - // Step 3: Try to retrieve using PHONE NUMBER address (THE BUG) + // Try to retrieve using PHONE NUMBER address (THE BUG) let phone_protocol_address = own_phone.to_protocol_address(); let phone_sender_key_name = SenderKeyName::new(group_jid.to_string(), phone_protocol_address.to_string()); @@ -1640,40 +1606,25 @@ mod tests { device_guard.load_sender_key(&phone_sender_key_name).await }; - println!( - "❌ Step 2: Lookup with phone number address failed (expected): {}", - phone_protocol_address - ); assert!( phone_lookup_result .expect("lookup should not error") .is_none(), - "Sender key should NOT be found when looking up with phone number address (this demonstrates the bug)" + "Sender key should NOT be found when looking up with phone number address (demonstrates the bug)" ); - // Step 4: Try to retrieve using LID address (THE FIX) + // Try to retrieve using LID address (THE FIX) let lid_lookup_result = { let mut device_guard = device_arc.write().await; device_guard.load_sender_key(&lid_sender_key_name).await }; - println!("✅ Step 3: Lookup with LID address succeeded (this is the fix)"); assert!( lid_lookup_result .expect("lookup should not error") .is_some(), "Sender key SHOULD be found when looking up with LID address (same as storage)" ); - - println!("\n🎯 Summary:"); - println!(" - LID protocol address: {}", lid_protocol_address); - println!(" - Phone protocol address: {}", phone_protocol_address); - println!( - " - Storage key format: {}:{}", - group_jid, lid_protocol_address - ); - println!(" - Bug: Using phone address for lookup after storing with LID address"); - println!(" - Fix: Always use info.source.sender (LID) for both storage and retrieval"); } /// Test that sender key consistency is maintained for multiple LID participants @@ -1780,11 +1731,6 @@ mod tests { lid_str ); } - - println!( - "✅ All {} LID participants have isolated sender keys", - participants.len() - ); } /// Test that LID JID parsing handles various edge cases correctly @@ -1985,8 +1931,6 @@ mod tests { .user, "15551234567" ); - - println!("✅ sender_alt extraction working correctly for LID groups"); } /// Test that device query logic uses phone numbers for LID participants @@ -2055,8 +1999,6 @@ mod tests { assert_eq!(jids_to_query.len(), 2); assert!(jids_to_query.iter().any(|j| j.user == "15551234567")); assert!(jids_to_query.iter().any(|j| j.user == "551234567890")); - - println!("✅ LID-to-phone mapping working correctly for device queries"); } /// Test edge case: Group with mixed LID and phone number participants @@ -2110,8 +2052,6 @@ mod tests { for jid in &jids_to_query { assert_eq!(jid.server, SERVER_JID); } - - println!("✅ Mixed LID and phone number participants handled correctly"); } /// Test edge case: Own JID check in LID mode @@ -2147,8 +2087,6 @@ mod tests { // Verify we're checking using the phone number assert_eq!(own_jid_to_check.user, "15551234567"); assert_eq!(own_jid_to_check.server, SERVER_JID); - - println!("✅ Own JID check correctly uses phone number in LID mode"); } /// Test that sender key operations always use the display JID (LID) @@ -2231,8 +2169,6 @@ mod tests { .is_none(), "Sender key should NOT be found with encryption JID (phone number)" ); - - println!("✅ Sender key operations correctly use display JID, not encryption JID"); } /// Test edge case: Second message with only skmsg (no pkmsg/msg) @@ -2294,10 +2230,7 @@ mod tests { .expect("Failed to process SKDM"); } - println!("✅ Step 1: Sender key established for {}", sender_jid); - - // Step 2: Create a message with ONLY skmsg (no pkmsg/msg) - // This simulates the second message after session is established + // Create message with ONLY skmsg (simulating second message after session established) let skmsg_ciphertext = { let mut device_guard = device_arc.write().await; let sender_key_msg = wacore::libsignal::protocol::group_encrypt( @@ -2329,19 +2262,8 @@ mod tests { .build(), ); - // Step 3: Handle the message (should NOT skip skmsg) - // Before the fix, this would log: - // "Skipping skmsg decryption for message SECOND_MSG_TEST from 100000000000001.1:75@lid - // because the initial session/senderkey message failed to decrypt." - // - // After the fix, it should decrypt successfully. + // Should NOT skip skmsg - before the fix this would incorrectly skip client.handle_encrypted_message(message_node).await; - - println!("✅ Step 2: Second message with only skmsg processed successfully"); - - // The test passes if we reach here without errors - // In a real scenario, we'd verify the message was decrypted and the event was dispatched - // For now, we're just ensuring the code path doesn't skip the skmsg incorrectly } /// Test case for UntrustedIdentity error handling and recovery @@ -2417,14 +2339,7 @@ mod tests { success ); - // The key here is that this didn't panic or crash - // The fix ensures that when UntrustedIdentity occurs, the deletion uses the full - // protocol address (e.g., "559981212574.0") not just the name part (e.g., "559981212574") - println!("✅ UntrustedIdentity error handling:"); - println!(" - Error caught gracefully without panic"); - println!(" - Deletion uses full protocol address: ."); - println!(" - No fatal error propagated"); - println!(" - Process continues normally"); + // The key is that this didn't panic - deletion uses full protocol address } /// Test case: Error handling during batch processing @@ -2495,11 +2410,6 @@ mod tests { .await; log::info!("Test: Batch processing completed - success: {}", success); - - println!("✅ Error handling in batch processing:"); - println!(" - Multiple messages processed without panic"); - println!(" - Each error handled independently"); - println!(" - Batch processor continues through all messages"); } /// Test case: Error handling in group chat context @@ -2560,11 +2470,6 @@ mod tests { .await; log::info!("Test: Group message processed - success: {}", success); - - println!("✅ Error handling in group chat:"); - println!(" - Sender with error handled gracefully"); - println!(" - No panic when processing group messages with errors"); - println!(" - Error doesn't affect group processing"); } /// Test case: DM message parsing for self-sent messages via LID @@ -2644,22 +2549,15 @@ mod tests { "sender_alt should be None for self-sent DMs (peer_recipient_pn is recipient's PN)" ); - // 3. Chat should be the recipient assert_eq!( info.source.chat.user, "39492358562039", "Chat should be the recipient's LID" ); - // 4. Sender should be own LID assert_eq!( info.source.sender.user, "100000000000001", "Sender should be own LID" ); - - println!("✅ Self-sent DM via LID:"); - println!(" - is_from_me correctly detected: true"); - println!(" - sender_alt correctly NOT set (peer_recipient_pn is recipient's PN)"); - println!(" - Decryption will use own PN via is_from_me fallback path"); } /// Test case: DM message parsing for messages from others via LID @@ -2723,14 +2621,11 @@ mod tests { .await .expect("parse_message_info should succeed"); - // Assertions: - // 1. is_from_me should be false assert!( !info.source.is_from_me, "Should NOT be detected as self-sent" ); - // 2. sender_alt should be populated from sender_pn assert!( info.source.sender_alt.is_some(), "sender_alt should be set from sender_pn attribute" @@ -2745,22 +2640,15 @@ mod tests { "sender_alt should contain sender's phone number" ); - // 3. Chat should be the sender (non-AD version) assert_eq!( info.source.chat.user, "39492358562039", "Chat should be the sender's LID (non-AD)" ); - // 4. Sender should be the other user's LID assert_eq!( info.source.sender.user, "39492358562039", "Sender should be other user's LID" ); - - println!("✅ DM from other user via LID:"); - println!(" - is_from_me correctly detected: false"); - println!(" - sender_alt correctly set from sender_pn attribute"); - println!(" - Decryption will use sender_alt for session lookup"); } /// Test case: DM message to self (own chat, like "Notes to Myself") @@ -2822,35 +2710,25 @@ mod tests { .await .expect("parse_message_info should succeed"); - // Assertions: - // 1. is_from_me should be true assert!( info.source.is_from_me, "Should detect self-sent message to self-chat" ); - // 2. sender_alt should be None (we don't use peer_recipient_pn for self-sent) assert!( info.source.sender_alt.is_none(), "sender_alt should be None for self-sent messages" ); - // 3. Chat should be the recipient (self) assert_eq!( info.source.chat.user, "100000000000001", "Chat should be self (recipient)" ); - // 4. Sender should be own LID assert_eq!( info.source.sender.user, "100000000000001", "Sender should be own LID" ); - - println!("✅ DM to self (self-chat):"); - println!(" - is_from_me correctly detected: true"); - println!(" - sender_alt correctly NOT set"); - println!(" - Decryption will use own PN via is_from_me fallback path"); } /// Test that receiving a DM with sender_lid populates the lid_pn_cache. @@ -2921,13 +2799,6 @@ mod tests { lid, "Cached LID should match the sender_lid from the message" ); - - println!("✅ test_lid_pn_cache_populated_on_message_with_sender_lid passed:"); - println!( - " - Received DM from {}@s.whatsapp.net with sender_lid={}@lid", - phone, lid - ); - println!(" - Cache correctly populated: {} -> {}", phone, lid); } /// Test that messages without sender_lid do NOT populate the cache. @@ -2970,15 +2841,10 @@ mod tests { .handle_encrypted_message(Arc::new(dm_node)) .await; - // Verify the cache was NOT populated assert!( client.lid_pn_cache.get_current_lid(phone).await.is_none(), "Cache should NOT be populated for messages without sender_lid" ); - - println!("✅ test_lid_pn_cache_not_populated_without_sender_lid passed:"); - println!(" - Received DM without sender_lid attribute"); - println!(" - Cache correctly remains empty"); } /// Test that messages from LID senders with participant_pn DO populate the cache. @@ -3048,10 +2914,6 @@ mod tests { phone, "Cached phone number should match" ); - - println!("✅ test_lid_pn_cache_populated_for_lid_sender_with_participant_pn passed:"); - println!(" - Received message from LID sender with participant_pn"); - println!(" - Cache correctly populated with bidirectional mapping"); } /// Test that multiple messages from the same sender update the cache correctly. @@ -3104,10 +2966,6 @@ mod tests { lid, "Cached LID should be correct after multiple messages" ); - - println!("✅ test_lid_pn_cache_handles_repeated_messages passed:"); - println!(" - Received 3 messages from same sender"); - println!(" - Cache correctly maintains the mapping"); } /// Test that PN-addressed messages use LID for session lookup when LID mapping is known. @@ -3255,10 +3113,6 @@ mod tests { format!("{}@lid.0", lid), "Protocol address should be in LID format" ); - - println!("✅ test_pn_message_uses_lid_for_session_lookup_when_mapping_known passed:"); - println!(" - PN message with sender_lid attribute correctly uses LID for session lookup"); - println!(" - Protocol address: {}", protocol_address); } /// Test that PN-addressed messages use cached LID even without sender_lid attribute. @@ -3368,10 +3222,6 @@ mod tests { format!("{}@lid.0", lid), "Protocol address should be in LID format from cached mapping" ); - - println!("✅ test_pn_message_uses_cached_lid_without_sender_lid_attribute passed:"); - println!(" - PN message without sender_lid attribute uses cached LID for session lookup"); - println!(" - Protocol address: {}", protocol_address); } /// Test that PN-addressed messages use PN when no LID mapping is known. @@ -3468,10 +3318,6 @@ mod tests { format!("{}@c.us.0", phone), "Protocol address should be in PN format when no LID mapping" ); - - println!("✅ test_pn_message_uses_pn_when_no_lid_mapping passed:"); - println!(" - PN message without LID mapping uses PN for session lookup"); - println!(" - Protocol address: {}", protocol_address); } // and PDO fallback behavior to ensure robust message recovery. @@ -4002,7 +3848,6 @@ mod tests { /// must be a hard error rather than defaulting to an empty string. #[tokio::test] async fn test_parse_message_info_missing_id_returns_error() { - // 1. Setup let backend = Arc::new( SqliteStore::new("file:memdb_missing_id_test?mode=memory&cache=shared") .await @@ -4015,17 +3860,14 @@ mod tests { ); let (client, _sync_rx) = Client::new(pm, mock_transport(), mock_http_client(), None).await; - // 2. Create a message node WITHOUT the "id" attribute let node = NodeBuilder::new("message") .attr("from", "15551234567@s.whatsapp.net") .attr("t", "1759295366") .attr("type", "text") .build(); - // 3. Run the function under test - should return an error let result = client.parse_message_info(&node).await; - // 4. Assert that it returns an error about missing id assert!( result.is_err(), "parse_message_info should fail when 'id' is missing" diff --git a/src/send.rs b/src/send.rs index 93cdf89d4..43790024b 100644 --- a/src/send.rs +++ b/src/send.rs @@ -173,7 +173,6 @@ impl Client { .await; let _session_guard = session_mutex.lock().await; - // Lock is held only during encryption let device_store_arc = self.persistence_manager.get_device_arc().await; let mut store_adapter = SignalProtocolStoreAdapter::new(device_store_arc); @@ -185,7 +184,6 @@ impl Client { request_id, ) .await? - // Lock released here automatically } else if to.is_group() { // Group messages: No client-level lock needed. // Each participant device is encrypted separately with its own per-device lock @@ -470,7 +468,6 @@ impl Client { .await; let _session_guard = session_mutex.lock().await; - // Lock is held only during encryption let device_store_arc = self.persistence_manager.get_device_arc().await; let mut store_adapter = SignalProtocolStoreAdapter::new(device_store_arc); @@ -494,9 +491,8 @@ impl Client { extra_stanza_nodes, ) .await? - // Lock released here automatically }; - // Network send happens with NO lock held + self.send_node(stanza_to_send).await.map_err(|e| e.into()) } } diff --git a/src/usync.rs b/src/usync.rs index 499526f59..df4e16f02 100644 --- a/src/usync.rs +++ b/src/usync.rs @@ -15,17 +15,14 @@ impl Client { let mut jids_to_fetch: HashSet = HashSet::new(); let mut all_devices = Vec::new(); - // 1. Check the cache first for jid in jids.iter().map(|j| j.to_non_ad()) { if let Some(cached_devices) = self.get_device_cache().await.get(&jid).await { all_devices.extend(cached_devices); - continue; // Found fresh entry, skip network fetch + continue; } - // Not in cache or stale, add to the fetch set (de-duplicated) jids_to_fetch.insert(jid); } - // 2. Fetch missing JIDs from the network if !jids_to_fetch.is_empty() { debug!( "get_user_devices: Cache miss, fetching from network for {} unique users", @@ -60,7 +57,6 @@ impl Client { ); } - // 3. Update the cache with the newly fetched data (now with phash) for user_list in &response.device_lists { self.get_device_cache() .await diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 31e019139..0b49e3118 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -55,7 +55,6 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { let name = &input.ident; - // Extract tag from #[protocol(tag = "...")] let tag = match extract_tag(&input.attrs) { Some(tag) => tag, None => { @@ -68,14 +67,10 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } }; - // Get fields for struct let fields = match &input.data { Data::Struct(data) => match &data.fields { Fields::Named(fields) => &fields.named, - Fields::Unit => { - // Unit struct - no fields - return generate_empty_impl(name, &tag).into(); - } + Fields::Unit => return generate_empty_impl(name, &tag).into(), _ => { return syn::Error::new_spanned( &input.ident, @@ -95,7 +90,6 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } }; - // Collect field info let mut attr_fields = Vec::new(); for field in fields { if let Some(attr_info) = extract_attr_info(field) { @@ -103,7 +97,6 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } } - // Generate into_node() body let attr_setters: Vec<_> = attr_fields .iter() .map(|info| { @@ -115,7 +108,6 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { }) .collect(); - // Generate try_from_node() body let field_parsers: Vec<_> = attr_fields .iter() .map(|info| { @@ -137,7 +129,6 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { }) .collect(); - // Generate Default impl field initializers let default_fields: Vec<_> = attr_fields .iter() .map(|info| { @@ -203,7 +194,6 @@ pub fn derive_empty_node(input: TokenStream) -> TokenStream { let name = &input.ident; - // Extract tag from #[protocol(tag = "...")] let tag = match extract_tag(&input.attrs) { Some(tag) => tag, None => { @@ -276,7 +266,6 @@ fn extract_attr_info(field: &syn::Field) -> Option { for attr in &field.attrs { if attr.path().is_ident("attr") { - // Parse the attribute arguments let mut attr_name = None; let mut default = None; diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs index aee56da03..98c9e6e4d 100644 --- a/wacore/src/iq/dirty.rs +++ b/wacore/src/iq/dirty.rs @@ -28,11 +28,8 @@ pub const DIRTY_NAMESPACE: &str = "urn:xmpp:whatsapp:dirty"; /// Known dirty bit types. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DirtyType { - /// Account sync dirty bit AccountSync, - /// Groups dirty bit Groups, - /// Other/unknown type Other(String), } @@ -59,14 +56,11 @@ impl From<&str> for DirtyType { /// A dirty bit to clean. #[derive(Debug, Clone)] pub struct DirtyBit { - /// The type of dirty bit. pub dirty_type: DirtyType, - /// Optional timestamp for the dirty bit. pub timestamp: Option, } impl DirtyBit { - /// Create a new dirty bit with just a type. pub fn new(dirty_type: impl Into) -> Self { Self { dirty_type: dirty_type.into(), @@ -74,7 +68,6 @@ impl DirtyBit { } } - /// Create a new dirty bit with a type and timestamp. pub fn with_timestamp(dirty_type: impl Into, timestamp: u64) -> Self { Self { dirty_type: dirty_type.into(), @@ -86,15 +79,11 @@ impl DirtyBit { /// Clears dirty bits on the server. #[derive(Debug, Clone)] pub struct CleanDirtyBitsSpec { - /// The dirty bits to clean. pub bits: Vec, } impl CleanDirtyBitsSpec { - /// Create a spec to clean a single dirty bit. - /// - /// # Errors - /// Returns an error if `timestamp` is provided but cannot be parsed as `u64`. + /// Returns error if `timestamp` cannot be parsed as `u64`. pub fn single(dirty_type: &str, timestamp: Option<&str>) -> Result { let bit = if let Some(ts) = timestamp { let ts_num: u64 = ts @@ -107,7 +96,6 @@ impl CleanDirtyBitsSpec { Ok(Self { bits: vec![bit] }) } - /// Create a spec to clean multiple dirty bits. pub fn multiple(bits: Vec) -> Self { Self { bits } } From d1228453e7ea1dcd15e314f807efc93813303574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 16:21:07 -0300 Subject: [PATCH 14/18] fix: reject empty 'from' in message stanzas --- src/handlers/message.rs | 8 ++++++-- wacore/derive/src/lib.rs | 43 +++++++++++++++++++++++----------------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/handlers/message.rs b/src/handlers/message.rs index b882d8caa..6cabed3d3 100644 --- a/src/handlers/message.rs +++ b/src/handlers/message.rs @@ -31,9 +31,13 @@ impl StanzaHandler for MessageHandler { // This prevents race conditions where a later message is processed before // the PreKey message that establishes the session. let chat_id = match node.attrs().optional_string("from") { - Some(id) => id.to_string(), + Some(id) if !id.is_empty() => id.to_string(), + Some(_) => { + warn!("Message stanza has empty 'from' attribute"); + return false; + } None => { - log::warn!("Message stanza missing required 'from' attribute"); + warn!("Message stanza missing required 'from' attribute"); return false; } }; diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 0b49e3118..bce5aa770 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -56,8 +56,8 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { let name = &input.ident; let tag = match extract_tag(&input.attrs) { - Some(tag) => tag, - None => { + Ok(Some(tag)) => tag, + Ok(None) => { return syn::Error::new_spanned( &input.ident, "ProtocolNode requires #[protocol(tag = \"...\")]", @@ -65,6 +65,7 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { .to_compile_error() .into(); } + Err(e) => return e.to_compile_error().into(), }; let fields = match &input.data { @@ -92,8 +93,10 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { let mut attr_fields = Vec::new(); for field in fields { - if let Some(attr_info) = extract_attr_info(field) { - attr_fields.push(attr_info); + match extract_attr_info(field) { + Ok(Some(attr_info)) => attr_fields.push(attr_info), + Ok(None) => {} + Err(e) => return e.to_compile_error().into(), } } @@ -195,8 +198,8 @@ pub fn derive_empty_node(input: TokenStream) -> TokenStream { let name = &input.ident; let tag = match extract_tag(&input.attrs) { - Some(tag) => tag, - None => { + Ok(Some(tag)) => tag, + Ok(None) => { return syn::Error::new_spanned( &input.ident, "EmptyNode requires #[protocol(tag = \"...\")]", @@ -204,6 +207,7 @@ pub fn derive_empty_node(input: TokenStream) -> TokenStream { .to_compile_error() .into(); } + Err(e) => return e.to_compile_error().into(), }; generate_empty_impl(name, &tag).into() @@ -242,34 +246,37 @@ struct AttrFieldInfo { default: Option, } -fn extract_tag(attrs: &[syn::Attribute]) -> Option { +fn extract_tag(attrs: &[syn::Attribute]) -> Result, syn::Error> { for attr in attrs { if attr.path().is_ident("protocol") { let mut tag = None; - let _ = attr.parse_nested_meta(|meta| { + attr.parse_nested_meta(|meta| { if meta.path.is_ident("tag") { let value: syn::LitStr = meta.value()?.parse()?; tag = Some(value.value()); } Ok(()) - }); + })?; if tag.is_some() { - return tag; + return Ok(tag); } } } - None + Ok(None) } -fn extract_attr_info(field: &syn::Field) -> Option { - let field_ident = field.ident.clone()?; +fn extract_attr_info(field: &syn::Field) -> Result, syn::Error> { + let field_ident = match field.ident.clone() { + Some(ident) => ident, + None => return Ok(None), + }; for attr in &field.attrs { if attr.path().is_ident("attr") { let mut attr_name = None; let mut default = None; - let _ = attr.parse_nested_meta(|meta| { + attr.parse_nested_meta(|meta| { if meta.path.is_ident("name") { let value: syn::LitStr = meta.value()?.parse()?; attr_name = Some(value.value()); @@ -278,18 +285,18 @@ fn extract_attr_info(field: &syn::Field) -> Option { default = Some(value.value()); } Ok(()) - }); + })?; if let Some(name) = attr_name { - return Some(AttrFieldInfo { + return Ok(Some(AttrFieldInfo { field_ident, attr_name: name, default, - }); + })); } } } - None + Ok(None) } /// Derive macro for enums with string representations. From dc67c7952599abb2f5335d1f150241e9b9682f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 16:45:33 -0300 Subject: [PATCH 15/18] fix: validate derive attributes and duplicate defaults --- wacore/derive/src/lib.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index bce5aa770..b92327e24 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -287,12 +287,20 @@ fn extract_attr_info(field: &syn::Field) -> Result, syn::E Ok(()) })?; - if let Some(name) = attr_name { - return Ok(Some(AttrFieldInfo { - field_ident, - attr_name: name, - default, - })); + match attr_name { + Some(name) => { + return Ok(Some(AttrFieldInfo { + field_ident, + attr_name: name, + default, + })); + } + None => { + return Err(syn::Error::new_spanned( + attr, + "missing required `name` in #[attr(...)]", + )); + } } } } @@ -383,6 +391,14 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { }; if is_default { + if default_variant.is_some() { + return syn::Error::new_spanned( + variant_ident, + "Multiple #[string_default] attributes found; only one variant may be the default", + ) + .to_compile_error() + .into(); + } default_variant = Some(variant_ident.clone()); } From 837993d57aaad113abeb14ae8bfec3bb7f44377f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 16:54:14 -0300 Subject: [PATCH 16/18] fix: reject non-unit variants in StringEnum derive --- wacore/derive/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index b92327e24..4d22becd0 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -358,6 +358,16 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { for variant in variants { let variant_ident = &variant.ident; + + if !matches!(variant.fields, syn::Fields::Unit) { + return syn::Error::new_spanned( + variant_ident, + "StringEnum only supports unit variants", + ) + .to_compile_error() + .into(); + } + let mut str_value = None; let mut is_default = false; From ec5256b8380d587ba33ae4bafa5e83c030013590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 17:08:16 -0300 Subject: [PATCH 17/18] fix: detect duplicate string enum attribute values --- wacore/derive/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 4d22becd0..1eda7d15e 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -355,6 +355,8 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { let mut variant_infos = Vec::new(); let mut default_variant = None; + let mut seen_str_values: std::collections::HashMap = + std::collections::HashMap::new(); for variant in variants { let variant_ident = &variant.ident; @@ -373,7 +375,6 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { for attr in &variant.attrs { if attr.path().is_ident("str") { - // Parse #[str = "value"] if let syn::Meta::NameValue(nv) = &attr.meta && let syn::Expr::Lit(expr_lit) = &nv.value && let syn::Lit::Str(lit_str) = &expr_lit.lit @@ -400,6 +401,19 @@ pub fn derive_string_enum(input: TokenStream) -> TokenStream { } }; + if let Some(prev_variant) = seen_str_values.get(&str_val) { + return syn::Error::new_spanned( + variant_ident, + format!( + "duplicate #[str = \"{}\"] value; already used by variant `{}`", + str_val, prev_variant + ), + ) + .to_compile_error() + .into(); + } + seen_str_values.insert(str_val.clone(), variant_ident.clone()); + if is_default { if default_variant.is_some() { return syn::Error::new_spanned( From 16ae41516f805bc9c922358176558f49271a7dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 23 Jan 2026 18:32:07 -0300 Subject: [PATCH 18/18] fix: only derive Default when all fields have defaults --- wacore/derive/src/lib.rs | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs index 1eda7d15e..a592b8d4e 100644 --- a/wacore/derive/src/lib.rs +++ b/wacore/derive/src/lib.rs @@ -132,17 +132,31 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { }) .collect(); - let default_fields: Vec<_> = attr_fields - .iter() - .map(|info| { - let field_ident = &info.field_ident; - if let Some(default) = &info.default { + // Only generate Default impl if all fields have defaults + let all_have_defaults = attr_fields.iter().all(|info| info.default.is_some()); + + let default_impl = if all_have_defaults { + let default_fields: Vec<_> = attr_fields + .iter() + .map(|info| { + let field_ident = &info.field_ident; + let default = info.default.as_ref().unwrap(); quote! { #field_ident: #default.to_string() } - } else { - quote! { #field_ident: String::new() } + }) + .collect(); + + quote! { + impl ::core::default::Default for #name { + fn default() -> Self { + Self { + #(#default_fields),* + } + } } - }) - .collect(); + } + } else { + quote! {} + }; let expanded = quote! { impl ::wacore::protocol::ProtocolNode for #name { @@ -166,13 +180,7 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream { } } - impl ::core::default::Default for #name { - fn default() -> Self { - Self { - #(#default_fields),* - } - } - } + #default_impl }; expanded.into()