diff --git a/src/client.rs b/src/client.rs index 8a3bf2e8c..272b87f37 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1539,12 +1539,11 @@ impl Client { pub async fn clean_dirty_bits( &self, - type_: &str, - timestamp: Option<&str>, + bit: wacore::iq::dirty::DirtyBit, ) -> Result<(), crate::request::IqError> { use wacore::iq::dirty::CleanDirtyBitsSpec; - let spec = CleanDirtyBitsSpec::single(type_, timestamp)?; + let spec = CleanDirtyBitsSpec::single(bit); self.execute(spec).await } diff --git a/src/client/context_impl.rs b/src/client/context_impl.rs index 375878a0e..73358b0f3 100644 --- a/src/client/context_impl.rs +++ b/src/client/context_impl.rs @@ -2,6 +2,7 @@ use crate::client::Client; use async_trait::async_trait; use std::collections::HashMap; use wacore::client::context::{GroupInfo, SendContextResolver}; +use wacore::iq::prekeys::PreKeyFetchReason; use wacore::libsignal::protocol::PreKeyBundle; use wacore_binary::jid::Jid; @@ -23,7 +24,8 @@ impl SendContextResolver for Client { &self, jids: &[Jid], ) -> Result, anyhow::Error> { - self.fetch_pre_keys(jids, Some("identity")).await + self.fetch_pre_keys(jids, Some(PreKeyFetchReason::Identity)) + .await } async fn resolve_group_info(&self, jid: &Jid) -> Result { diff --git a/src/client/sessions.rs b/src/client/sessions.rs index 83bd690e3..189f9c058 100644 --- a/src/client/sessions.rs +++ b/src/client/sessions.rs @@ -194,7 +194,9 @@ impl Client { return Ok(0); } - let prekey_bundles = self.fetch_pre_keys(jids, Some("identity")).await?; + let prekey_bundles = self + .fetch_pre_keys(jids, Some(wacore::iq::prekeys::PreKeyFetchReason::Identity)) + .await?; let device_store = self.persistence_manager.get_device_arc().await; let mut adapter = crate::store::signal_adapter::SignalProtocolStoreAdapter::new( diff --git a/src/features/mod.rs b/src/features/mod.rs index af741ab7b..cb5191632 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -37,8 +37,8 @@ pub use media_reupload::{MediaRetryResult, MediaReupload, MediaReuploadRequest}; pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; pub use newsletter::{ - Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, - NewsletterState, NewsletterVerification, + Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, + NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, }; pub use polls::{PollOptionResult, Polls}; diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 4f1fc3843..dfd927f89 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -4,6 +4,8 @@ //! Uses MEX (GraphQL) for metadata/management and standard IQ for message operations. //! Newsletter messages are plaintext (no Signal E2E encryption). +use wacore::StringEnum; + use crate::client::Client; use crate::features::mex::{MexError, MexRequest}; use prost::Message as ProtoMessage; @@ -17,6 +19,26 @@ use waproto::whatsapp as wa; // Types +#[derive(Debug, Clone, PartialEq, Eq, StringEnum)] +pub enum NewsletterMessageType { + #[str = "text"] + Text, + #[str = "media"] + Media, + #[str = "reaction"] + Reaction, + #[str = "revoke"] + Revoke, + #[str = "poll_creation"] + PollCreation, + #[str = "poll_vote"] + PollVote, + #[str = "edit"] + Edit, + #[string_fallback] + Other(String), +} + /// Newsletter verification status. #[derive(Debug, Clone)] pub enum NewsletterVerification { @@ -71,8 +93,8 @@ pub struct NewsletterMessage { pub server_id: u64, /// Message timestamp (Unix seconds). pub timestamp: u64, - /// Message type ("text", "media", etc.). - pub message_type: String, + /// Message type (text, media, reaction, etc.). + pub message_type: NewsletterMessageType, /// Whether the viewer is the sender. pub is_sender: bool, /// Decoded protobuf message (from `` bytes). @@ -573,8 +595,8 @@ fn parse_newsletter_messages_response( let message_type = msg_node .attrs .get("type") - .map(|v| v.as_str().into_owned()) - .unwrap_or_default(); + .map(|v| NewsletterMessageType::from(v.as_str().as_ref())) + .unwrap_or(NewsletterMessageType::Text); let is_sender = msg_node.attrs.get("is_sender").is_some_and(|v| v == "true"); @@ -600,3 +622,41 @@ fn parse_newsletter_messages_response( Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + use wacore_binary::builder::NodeBuilder; + + #[test] + fn test_missing_type_attribute_defaults_to_text() { + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("messages") + .children([NodeBuilder::new("message") + .attr("server_id", "42") + .attr("t", "1700000000") + .build()]) + .build()]) + .build(); + + let msgs = parse_newsletter_messages_response(&response).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].message_type, NewsletterMessageType::Text); + } + + #[test] + fn test_explicit_type_attribute_parsed() { + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("messages") + .children([NodeBuilder::new("message") + .attr("server_id", "1") + .attr("t", "1700000000") + .attr("type", "media") + .build()]) + .build()]) + .build(); + + let msgs = parse_newsletter_messages_response(&response).unwrap(); + assert_eq!(msgs[0].message_type, NewsletterMessageType::Media); + } +} diff --git a/src/handlers/ib.rs b/src/handlers/ib.rs index 2a5137a2b..04376e270 100644 --- a/src/handlers/ib.rs +++ b/src/handlers/ib.rs @@ -5,6 +5,8 @@ use async_trait::async_trait; use log::{debug, info, warn}; use std::sync::Arc; use wacore::appstate::patch_decode::WAPatchName; +use wacore::iq::dirty::{DirtyBit, DirtyType}; + use wacore_binary::node::{Node, NodeContent}; /// Handler for `<ib>` (information broadcast) stanzas. @@ -35,48 +37,53 @@ async fn handle_ib_impl(client: Arc<Client>, node: &Node) { match child.tag.as_ref() { "dirty" => { let mut attrs = child.attrs(); - let dirty_type = match attrs.optional_string("type") { + let dirty_type_str = match attrs.optional_string("type") { Some(t) => t.to_string(), None => { warn!("Dirty notification missing 'type' attribute"); continue; } }; - let timestamp = attrs.optional_string("timestamp").map(|s| s.to_string()); + let timestamp_str = attrs.optional_string("timestamp"); + + let bit = match DirtyBit::from_raw(&dirty_type_str, timestamp_str.as_deref()) { + Ok(b) => b, + Err(e) => { + warn!("Invalid dirty notification: {e}"); + continue; + } + }; + + let needs_offline_wait = matches!( + bit.dirty_type, + DirtyType::Groups | DirtyType::NewsletterMetadata + ); + let needs_resync = bit.dirty_type == DirtyType::SyncdAppState; debug!( - "Received dirty state notification for type: '{dirty_type}'. Sending clean IQ." + "Received dirty state notification for type: '{dirty_type_str}'. Sending clean IQ." ); let client_clone = client.clone(); - // WA Web gates `groups` and `newsletter_metadata` dirty types behind - // offlineDeliveryEnd — only process them after offline sync completes. - // `account_sync` and `syncd_app_state` run immediately. - // See WAWebHandleDirtyBits in 5Yec01dI04o.js:50765-50782. + // Groups/newsletter_metadata: wait for offline sync per WAWebHandleDirtyBits. client .runtime .spawn(Box::pin(async move { - if dirty_type == "groups" || dirty_type == "newsletter_metadata" { + if needs_offline_wait { client_clone.wait_for_offline_delivery_end().await; } if client_clone.is_shutting_down() { - debug!("Skipping clean dirty bits: client is shutting down"); return; } - if let Err(e) = client_clone - .clean_dirty_bits(&dirty_type, timestamp.as_deref()) - .await + if let Err(e) = client_clone.clean_dirty_bits(bit).await && !client_clone.is_shutting_down() { warn!("Failed to send clean dirty bits IQ: {e:?}"); } - // Re-sync app state collections when notified they are stale. - // Real WA Web re-syncs all collections on syncd_app_state dirty. - // See WAWebHandleDirtyBits → WAWebSyncdCollectionsStateMachine. - if dirty_type == "syncd_app_state" && !client_clone.is_shutting_down() { - info!("syncd_app_state dirty — re-syncing all app state collections"); + if needs_resync && !client_clone.is_shutting_down() { + info!("syncd_app_state dirty -- re-syncing all app state collections"); if let Err(e) = client_clone .sync_collections_batched(vec![ WAPatchName::CriticalBlock, diff --git a/src/lib.rs b/src/lib.rs index eb67a425b..2b916aa2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,11 +61,12 @@ pub use features::{ GroupSubject, GroupType, Groups, IsOnWhatsAppResult, JoinGroupResult, LinkSubgroupsResult, MediaRetryResult, MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, MembershipApprovalMode, MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, - NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, Presence, - PresenceError, PresenceStatus, Profile, ProfilePicture, SetProfilePictureResponse, Status, - StatusPrivacySetting, StatusSendOptions, SyncActionMessageRange, TcToken, - UnlinkSubgroupsResult, UserInfo, group_type, message_key, message_range, + MexResponse, Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, + NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, + ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, + SetProfilePictureResponse, Status, StatusPrivacySetting, StatusSendOptions, + SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, group_type, message_key, + message_range, }; pub mod bot; diff --git a/src/mediaconn.rs b/src/mediaconn.rs index 55d27c9dd..dcd881e4b 100644 --- a/src/mediaconn.rs +++ b/src/mediaconn.rs @@ -8,8 +8,8 @@ use std::time::Duration; use wacore::iq::mediaconn::MediaConnSpec; use wacore::time::Instant; -/// Re-export the host type from wacore. -pub use wacore::iq::mediaconn::MediaConnHost; +/// Re-export protocol types from wacore. +pub use wacore::iq::mediaconn::{HostType, MediaConnHost}; /// Number of retry attempts after a media auth error (401/403). /// On auth failure, the media connection is invalidated and refreshed before retrying. diff --git a/src/message.rs b/src/message.rs index 19f00e590..ee32f2536 100644 --- a/src/message.rs +++ b/src/message.rs @@ -17,6 +17,7 @@ use wacore::libsignal::protocol::{ PublicKey as SignalPublicKey, SENDERKEY_MESSAGE_CURRENT_VERSION, }; use wacore::libsignal::store::sender_key_name::SenderKeyName; +use wacore::message_processing::EncType; use wacore::types::jid::JidExt; use wacore_binary::jid::Jid; use wacore_binary::jid::JidExt as _; @@ -442,9 +443,9 @@ impl Client { } // Fall back to built-in handlers - match enc_type.as_ref() { - "pkmsg" | "msg" => session_enc_nodes.push(enc_node), - "skmsg" => group_content_enc_nodes.push(enc_node), + match EncType::from_wire(enc_type.as_ref()) { + Some(et) if et.is_session() => session_enc_nodes.push(enc_node), + Some(EncType::SenderKey) => group_content_enc_nodes.push(enc_node), _ => log::warn!("Unknown enc type: {enc_type}"), } } @@ -454,9 +455,11 @@ impl Client { // so the skmsg decryption would fail with NoSenderKey. if !session_enc_nodes.is_empty() && !group_content_enc_nodes.is_empty() - && all_enc_nodes - .first() - .is_some_and(|n| n.attrs.get("type").is_some_and(|v| v == "skmsg")) + && all_enc_nodes.first().is_some_and(|n| { + n.attrs + .get("type") + .is_some_and(|v| v == EncType::SenderKey.as_wire_str()) + }) { log::error!( "[msg:{}] Protocol violation: skmsg is first in multi-enc message from {}. \ @@ -697,8 +700,9 @@ impl Client { } }; let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8; + let enc_type_enum = EncType::from_wire(enc_type.as_ref()); - let parsed_message = if enc_type.as_ref() == "pkmsg" { + let parsed_message = if enc_type_enum == Some(EncType::PreKeyMessage) { match PreKeySignalMessage::try_from(ciphertext) { Ok(m) => CiphertextMessage::PreKeySignalMessage(m), Err(e) => { @@ -716,7 +720,7 @@ impl Client { } }; - if enc_type.as_ref() == "pkmsg" { + if enc_type_enum == Some(EncType::PreKeyMessage) { // FLAGGED FOR DEBUGGING: "Bad Mac" Reproducibility #[cfg(feature = "debug-snapshots")] { @@ -2086,6 +2090,7 @@ mod tests { async fn test_parse_message_info_sender_alt_extraction() { use crate::store::SqliteStore; use std::sync::Arc; + use wacore::types::message::AddressingMode; use wacore_binary::builder::NodeBuilder; let backend = Arc::new( @@ -2129,7 +2134,7 @@ mod tests { .attr("from", "120363021033254949@g.us") .attr("participant", "987654321000000.2:42@lid") .attr("participant_pn", "551234567890:42@s.whatsapp.net") - .attr("addressing_mode", "lid") + .attr("addressing_mode", AddressingMode::Lid.as_str()) .attr("id", "test1") .attr("t", "12345") .build(); @@ -2155,7 +2160,7 @@ mod tests { .attr("from", "120363021033254949@g.us") .attr("participant", "100000000000001.1:75@lid") .attr("participant_pn", "15551234567:75@s.whatsapp.net") - .attr("addressing_mode", "lid") + .attr("addressing_mode", AddressingMode::Lid.as_str()) .attr("id", "test2") .attr("t", "12346") .build(); @@ -2441,6 +2446,7 @@ mod tests { create_sender_key_distribution_message, process_sender_key_distribution_message, }; use wacore::libsignal::store::sender_key_name::SenderKeyName; + use wacore::types::message::AddressingMode; use wacore_binary::builder::NodeBuilder; let backend = Arc::new( @@ -2517,7 +2523,7 @@ mod tests { .attr("id", "SECOND_MSG_TEST") .attr("t", "1759306493") .attr("type", "text") - .attr("addressing_mode", "lid") + .attr("addressing_mode", AddressingMode::Lid.as_str()) .children(vec![skmsg_node]) .build(), ); @@ -3183,6 +3189,8 @@ mod tests { /// 2. This enables sending to users we've only seen as LID senders #[tokio::test] async fn test_lid_pn_cache_populated_for_lid_sender_with_participant_pn() { + use wacore::types::message::AddressingMode; + // Setup client let backend = Arc::new( SqliteStore::new("file:memdb_lid_sender_test?mode=memory&cache=shared") @@ -3212,7 +3220,7 @@ mod tests { .attr("from", "120363123456789012@g.us") // Group chat .attr("participant", Jid::lid(lid).to_string()) // Sender is LID .attr("participant_pn", Jid::pn(phone).to_string()) // Their phone number - .attr("addressing_mode", "lid") // Required for participant_pn to be parsed + .attr("addressing_mode", AddressingMode::Lid.as_str()) // Required for participant_pn to be parsed .attr("id", "TEST123456789") .attr("t", "1765482972") .attr("type", "text") @@ -3687,7 +3695,7 @@ mod tests { /// Helper to create a test MessageInfo with customizable fields fn create_test_message_info(chat: &str, msg_id: &str, sender: &str) -> MessageInfo { - use wacore::types::message::{EditAttribute, MessageSource, MsgMetaInfo}; + use wacore::types::message::{EditAttribute, MessageCategory, MessageSource, MsgMetaInfo}; let chat_jid: Jid = chat.parse().expect("valid chat JID"); let sender_jid: Jid = sender.parse().expect("valid sender JID"); @@ -3709,7 +3717,7 @@ mod tests { }, timestamp: wacore::time::now_utc(), push_name: "Test User".to_string(), - category: "".to_string(), + category: MessageCategory::default(), multicast: false, media_type: "".to_string(), edit: EditAttribute::default(), diff --git a/src/pdo.rs b/src/pdo.rs index 9a9f63026..e168fac06 100644 --- a/src/pdo.rs +++ b/src/pdo.rs @@ -21,7 +21,7 @@ use log::{debug, info, warn}; use prost::Message; use std::sync::Arc; use std::time::Duration; -use wacore::types::message::{EditAttribute, MessageSource, MsgMetaInfo}; +use wacore::types::message::{EditAttribute, MessageCategory, MessageSource, MsgMetaInfo}; use wacore_binary::jid::{Jid, JidExt}; use waproto::whatsapp as wa; @@ -400,7 +400,7 @@ impl Client { }, timestamp, push_name: web_msg.push_name.clone().unwrap_or_default(), - category: String::new(), + category: MessageCategory::default(), multicast: false, media_type: String::new(), edit: EditAttribute::default(), diff --git a/src/prekeys.rs b/src/prekeys.rs index dfb641af1..ec590b37f 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -10,7 +10,7 @@ use log; use std::sync::atomic::Ordering; use wacore::iq::prekeys::{ - DigestKeyBundleSpec, PreKeyCountSpec, PreKeyFetchSpec, PreKeyUploadSpec, + DigestKeyBundleSpec, PreKeyCountSpec, PreKeyFetchReason, PreKeyFetchSpec, PreKeyUploadSpec, }; use wacore::libsignal::protocol::{KeyPair, PreKeyBundle, PublicKey}; use wacore::libsignal::store::record_helpers::new_pre_key_record; @@ -27,7 +27,7 @@ impl Client { pub(crate) async fn fetch_pre_keys( &self, jids: &[Jid], - reason: Option<&str>, + reason: Option<PreKeyFetchReason>, ) -> Result<std::collections::HashMap<Jid, PreKeyBundle>, anyhow::Error> { let spec = match reason { Some(r) => PreKeyFetchSpec::with_reason(jids.to_vec(), r), diff --git a/src/receipt.rs b/src/receipt.rs index a25e49b95..8c4cc176e 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -3,6 +3,7 @@ use crate::types::events::{Event, Receipt}; use crate::types::presence::ReceiptType; use log::debug; use std::sync::Arc; +use wacore::types::message::MessageCategory; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, JidExt as _}; @@ -23,7 +24,7 @@ impl Client { // messages (category="peer"). These tell the primary phone that // this companion device received the message. // For all other messages, skip receipts for our own messages. - info.category == "peer" || !info.source.is_from_me + info.category == MessageCategory::Peer || !info.source.is_from_me } pub(crate) async fn handle_receipt(self: &Arc<Self>, node: Arc<Node>) { @@ -133,7 +134,7 @@ impl Client { // WA Web: peer device messages (category="peer") use type="peer_msg". // Normal delivery receipts omit the type attribute (DROP_ATTR). - if info.category == "peer" { + if info.category == MessageCategory::Peer { builder = builder.attr("type", "peer_msg"); } @@ -145,7 +146,7 @@ impl Client { let receipt_node = builder.build(); debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}", - if info.category == "peer" { "peer_msg" } else { "delivery" }, + if info.category == MessageCategory::Peer { "peer_msg" } else { "delivery" }, info.id, info.source.sender); if let Err(e) = self.send_node(receipt_node).await { @@ -464,7 +465,7 @@ mod tests { is_group: false, ..Default::default() }, - category: "peer".to_string(), + category: MessageCategory::Peer, ..Default::default() }; diff --git a/src/retry.rs b/src/retry.rs index 32e1aa7ed..c5d7b8993 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -3,6 +3,7 @@ use crate::message::RetryReason; use crate::types::events::Receipt; use log::{info, warn}; use prost::Message; +use wacore::types::message::MessageCategory; use scopeguard; use std::sync::Arc; @@ -782,8 +783,8 @@ impl Client { .is_some_and(|lid| info.source.sender.is_same_user_as(lid)); if is_from_own_account { - if info.category == "peer" { - builder = builder.attr("category", "peer"); + if info.category == MessageCategory::Peer { + builder = builder.attr("category", MessageCategory::Peer.as_str()); } else { // Include recipient so the sender can look up the original message. // Without this, the retry fails silently (getTargetChat returns null). @@ -964,7 +965,7 @@ mod tests { /// Matches WhatsApp Web's sendRetryReceipt: if (to.isUser()) { if (isMeAccount(to)) { ... } } #[test] fn retry_receipt_attributes_for_device_sync_vs_peer_vs_group() { - use wacore::types::message::{MessageInfo, MessageSource}; + use wacore::types::message::{MessageCategory, MessageInfo, MessageSource}; use wacore_binary::builder::NodeBuilder; let our_pn = Jid::pn("559999999999"); @@ -989,8 +990,8 @@ mod tests { || info.source.sender.is_same_user_as(our_lid); if is_from_own_account { - if info.category == "peer" { - builder = builder.attr("category", "peer"); + if info.category == MessageCategory::Peer { + builder = builder.attr("category", MessageCategory::Peer.as_str()); } else { let recipient = info.source.recipient.as_ref().unwrap_or(&info.source.chat); builder = builder.attr("recipient", recipient.clone()); @@ -1013,7 +1014,7 @@ mod tests { recipient: Some(recipient_lid.clone()), ..Default::default() }, - category: String::new(), + category: MessageCategory::default(), ..Default::default() }; @@ -1046,7 +1047,7 @@ mod tests { recipient: None, ..Default::default() }, - category: "peer".to_string(), + category: MessageCategory::Peer, ..Default::default() }; @@ -1072,7 +1073,7 @@ mod tests { recipient: None, ..Default::default() }, - category: String::new(), + category: MessageCategory::default(), ..Default::default() }; @@ -1101,7 +1102,7 @@ mod tests { recipient: None, ..Default::default() }, - category: String::new(), + category: MessageCategory::default(), ..Default::default() }; diff --git a/wacore/src/iq/business.rs b/wacore/src/iq/business.rs index 3d68a9ff5..e51dd5309 100644 --- a/wacore/src/iq/business.rs +++ b/wacore/src/iq/business.rs @@ -1,5 +1,6 @@ //! Business profile IQ specification (namespace `w:biz`). +use crate::StringEnum; use crate::iq::node::optional_attr; use crate::iq::spec::IqSpec; use crate::request::InfoQuery; @@ -7,6 +8,50 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::{Node, NodeContent}; +#[derive(Debug, Clone, PartialEq, Eq, StringEnum)] +pub enum DayOfWeek { + #[str = "sun"] + Sunday, + #[str = "mon"] + Monday, + #[str = "tue"] + Tuesday, + #[str = "wed"] + Wednesday, + #[str = "thu"] + Thursday, + #[str = "fri"] + Friday, + #[str = "sat"] + Saturday, + #[string_fallback] + Other(String), +} + +impl serde::Serialize for DayOfWeek { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, StringEnum)] +pub enum BusinessHourMode { + #[str = "open_24h"] + Open24H, + #[str = "specific_hours"] + SpecificHours, + #[str = "appointment_only"] + AppointmentOnly, + #[string_fallback] + Other(String), +} + +impl serde::Serialize for BusinessHourMode { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(self.as_str()) + } +} + fn node_text(node: &Node) -> Option<String> { match &node.content { Some(NodeContent::String(s)) => Some(s.clone()), @@ -40,8 +85,8 @@ pub struct BusinessHours { #[derive(Debug, Clone, serde::Serialize)] pub struct BusinessHoursConfig { - pub day_of_week: String, - pub mode: String, + pub day_of_week: DayOfWeek, + pub mode: BusinessHourMode, #[serde(skip_serializing_if = "Option::is_none")] pub open_time: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] @@ -131,11 +176,11 @@ impl IqSpec for BusinessProfileSpec { let configs: Vec<BusinessHoursConfig> = bh_node .get_children_by_tag("business_hours_config") .filter_map(|c| { - let day = optional_attr(c, "day_of_week")?.into_owned(); - let mode = optional_attr(c, "mode")?.into_owned(); + let day = optional_attr(c, "day_of_week")?; + let mode_str = optional_attr(c, "mode")?; Some(BusinessHoursConfig { - day_of_week: day, - mode, + day_of_week: DayOfWeek::from(day.as_ref()), + mode: BusinessHourMode::from(mode_str.as_ref()), open_time: optional_attr(c, "open_time").map(|s| s.into_owned()), close_time: optional_attr(c, "close_time").map(|s| s.into_owned()), }) diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs index 3a96cac4b..be8437f40 100644 --- a/wacore/src/iq/dirty.rs +++ b/wacore/src/iq/dirty.rs @@ -5,21 +5,31 @@ 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, StringEnum)] pub enum DirtyType { #[str = "account_sync"] AccountSync, #[str = "groups"] Groups, + #[str = "syncd_app_state"] + SyncdAppState, + #[str = "newsletter_metadata"] + NewsletterMetadata, #[string_fallback] Other(String), } -/// A dirty bit to clean. +#[derive(Debug, thiserror::Error)] +pub enum DirtyBitParseError { + #[error("invalid timestamp '{value}': {source}")] + InvalidTimestamp { + value: String, + source: std::num::ParseIntError, + }, +} + #[derive(Debug, Clone)] pub struct DirtyBit { pub dirty_type: DirtyType, @@ -40,6 +50,23 @@ impl DirtyBit { timestamp: Some(timestamp), } } + + /// Parse from raw protocol node attributes. + pub fn from_raw(dirty_type: &str, timestamp: Option<&str>) -> Result<Self, DirtyBitParseError> { + let ts = timestamp + .map(|s| { + s.parse::<u64>() + .map_err(|e| DirtyBitParseError::InvalidTimestamp { + value: s.to_string(), + source: e, + }) + }) + .transpose()?; + Ok(Self { + dirty_type: DirtyType::from(dirty_type), + timestamp: ts, + }) + } } /// Clears dirty bits on the server. @@ -49,17 +76,15 @@ pub struct CleanDirtyBitsSpec { } impl CleanDirtyBitsSpec { - /// Returns error if `timestamp` cannot be parsed as `u64`. - pub fn single(dirty_type: &str, timestamp: Option<&str>) -> Result<Self, anyhow::Error> { - let bit = if let Some(ts) = timestamp { - 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)) - }; - Ok(Self { bits: vec![bit] }) + pub fn single(bit: DirtyBit) -> Self { + Self { bits: vec![bit] } + } + + /// Parse from raw string attributes. Delegates to `DirtyBit::from_raw`. + pub fn from_raw(dirty_type: &str, timestamp: Option<&str>) -> Result<Self, DirtyBitParseError> { + Ok(Self { + bits: vec![DirtyBit::from_raw(dirty_type, timestamp)?], + }) } pub fn multiple(bits: Vec<DirtyBit>) -> Self { @@ -103,7 +128,7 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_single() { - let spec = CleanDirtyBitsSpec::single("account_sync", None).unwrap(); + let spec = CleanDirtyBitsSpec::single(DirtyBit::new(DirtyType::AccountSync)); let iq = spec.build_iq(); assert_eq!(iq.namespace, DIRTY_NAMESPACE); @@ -126,7 +151,8 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_with_timestamp() { - let spec = CleanDirtyBitsSpec::single("groups", Some("1234567890")).unwrap(); + let spec = + CleanDirtyBitsSpec::single(DirtyBit::with_timestamp(DirtyType::Groups, 1234567890)); let iq = spec.build_iq(); if let Some(NodeContent::Nodes(nodes)) = &iq.content { @@ -144,8 +170,8 @@ mod tests { } #[test] - fn test_clean_dirty_bits_spec_invalid_timestamp() { - let result = CleanDirtyBitsSpec::single("account_sync", Some("not_a_number")); + fn test_clean_dirty_bits_from_raw_invalid_timestamp() { + let result = CleanDirtyBitsSpec::from_raw("account_sync", Some("not_a_number")); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( @@ -155,6 +181,18 @@ mod tests { ); } + #[test] + fn test_clean_dirty_bits_from_raw() { + let spec = CleanDirtyBitsSpec::from_raw("groups", Some("1234567890")).unwrap(); + assert_eq!(spec.bits.len(), 1); + assert_eq!(spec.bits[0].dirty_type, DirtyType::Groups); + assert_eq!(spec.bits[0].timestamp, Some(1234567890)); + + let spec = CleanDirtyBitsSpec::from_raw("account_sync", None).unwrap(); + assert_eq!(spec.bits[0].dirty_type, DirtyType::AccountSync); + assert_eq!(spec.bits[0].timestamp, None); + } + #[test] fn test_clean_dirty_bits_spec_multiple() { let bits = vec![ @@ -187,7 +225,7 @@ mod tests { #[test] fn test_clean_dirty_bits_spec_parse_response() { - let spec = CleanDirtyBitsSpec::single("account_sync", None).unwrap(); + let spec = CleanDirtyBitsSpec::single(DirtyBit::new(DirtyType::AccountSync)); let response = NodeBuilder::new("iq").attr("type", "result").build(); let result = spec.parse_response(&response); @@ -198,6 +236,11 @@ mod tests { 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("syncd_app_state"), DirtyType::SyncdAppState); + assert_eq!( + DirtyType::from("newsletter_metadata"), + DirtyType::NewsletterMetadata + ); assert_eq!( DirtyType::from("other"), DirtyType::Other("other".to_string()) diff --git a/wacore/src/iq/mediaconn.rs b/wacore/src/iq/mediaconn.rs index 66133978f..916012091 100644 --- a/wacore/src/iq/mediaconn.rs +++ b/wacore/src/iq/mediaconn.rs @@ -16,6 +16,7 @@ //! </iq> //! ``` +use crate::StringEnum; use crate::iq::spec::IqSpec; use crate::protocol::ProtocolNode; use crate::request::InfoQuery; @@ -24,6 +25,17 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::{Node, NodeContent}; +#[derive(Debug, Clone, PartialEq, Eq, StringEnum)] +pub enum HostType { + #[str = "primary"] + #[string_default] + Primary, + #[str = "fallback"] + Fallback, + #[string_fallback] + Other(String), +} + /// Media connection host information. /// /// Hosts are sorted primary-first by `MediaConnSpec::parse_response`. @@ -31,8 +43,8 @@ use wacore_binary::node::{Node, NodeContent}; #[derive(Debug, Clone)] pub struct MediaConnHost { pub hostname: String, - /// `"primary"` or `"fallback"` — determines retry order. - pub host_type: String, + /// Determines retry order: primary hosts are tried first. + pub host_type: HostType, /// Fallback hostname to try if this host fails. pub fallback_hostname: Option<String>, } @@ -42,7 +54,7 @@ impl MediaConnHost { pub fn new(hostname: String) -> Self { Self { hostname, - host_type: "primary".to_string(), + host_type: HostType::Primary, fallback_hostname: None, } } @@ -52,7 +64,7 @@ impl MediaConnHost { #[derive(Debug, Clone)] pub struct MediaConnHostExtended { pub hostname: String, - pub host_type: String, // "primary" or "fallback" + pub host_type: HostType, pub fallback_hostname: Option<String>, pub ip4: Option<String>, pub ip6: Option<String>, @@ -66,7 +78,7 @@ pub struct MediaConnHostExtended { impl MediaConnHostExtended { /// Create a simple host (for fallback hosts). - pub fn simple(hostname: String, host_type: String) -> Self { + pub fn simple(hostname: String, host_type: HostType) -> Self { Self { hostname, host_type, @@ -93,7 +105,7 @@ impl MediaConnHostExtended { ) -> Self { Self { hostname, - host_type: "primary".to_string(), + host_type: HostType::Primary, fallback_hostname: Some(fallback_hostname), fallback_ip4: Some(ip4.clone()), fallback_ip6: Some(ip6.clone()), @@ -115,7 +127,7 @@ impl ProtocolNode for MediaConnHostExtended { fn into_node(self) -> Node { let mut builder = NodeBuilder::new("host") .attr("hostname", &self.hostname) - .attr("type", &self.host_type); + .attr("type", self.host_type.as_str()); if let Some(ref fallback_hostname) = self.fallback_hostname { builder = builder.attr("fallback_hostname", fallback_hostname); @@ -189,9 +201,8 @@ impl ProtocolNode for MediaConnHostExtended { .to_string(); let host_type = attrs .optional_string("type") - .as_deref() - .unwrap_or("primary") - .to_string(); + .map(|s| HostType::from(s.as_ref())) + .unwrap_or(HostType::Primary); Ok(Self { hostname, @@ -372,7 +383,13 @@ impl IqSpec for MediaConnSpec { }) }) .collect(); - hosts.sort_by_key(|h| if h.host_type == "primary" { 0 } else { 1 }); + hosts.sort_by_key(|h| { + if h.host_type == HostType::Primary { + 0 + } else { + 1 + } + }); Ok(MediaConnResponse { auth, @@ -463,7 +480,7 @@ mod tests { let parsed = MediaConnHostExtended::try_from_node(&node).unwrap(); assert_eq!(parsed.hostname, host.hostname); - assert_eq!(parsed.host_type, "primary"); + assert_eq!(parsed.host_type, HostType::Primary); assert!(parsed.upload); assert!(parsed.download); assert_eq!(parsed.download_categories.len(), 2); @@ -481,7 +498,7 @@ mod tests { vec!["image".to_string()], vec!["0".to_string()], ), - MediaConnHostExtended::simple("localhost:3000".to_string(), "fallback".to_string()), + MediaConnHostExtended::simple("localhost:3000".to_string(), HostType::Fallback), ]; let response = MediaConnResponseExtended::mock("test-auth".to_string(), 300, hosts); @@ -496,7 +513,7 @@ mod tests { assert_eq!(parsed.max_buckets, Some(12)); assert_eq!(parsed.ip_token, Some("MOCK_IP_TOKEN".to_string())); assert_eq!(parsed.hosts.len(), 2); - assert_eq!(parsed.hosts[0].host_type, "primary"); - assert_eq!(parsed.hosts[1].host_type, "fallback"); + assert_eq!(parsed.hosts[0].host_type, HostType::Primary); + assert_eq!(parsed.hosts[1].host_type, HostType::Fallback); } } diff --git a/wacore/src/iq/prekeys.rs b/wacore/src/iq/prekeys.rs index f4516cbd2..be82381bf 100644 --- a/wacore/src/iq/prekeys.rs +++ b/wacore/src/iq/prekeys.rs @@ -117,11 +117,21 @@ impl IqSpec for PreKeyCountSpec { } } +#[derive(Debug, Clone, PartialEq, Eq, crate::StringEnum)] +pub enum PreKeyFetchReason { + #[str = "identity"] + Identity, + #[str = "retry"] + Retry, + #[string_fallback] + Other(String), +} + /// Fetches pre-key bundles for a list of JIDs. #[derive(Debug, Clone)] pub struct PreKeyFetchSpec { pub jids: Vec<Jid>, - pub reason: Option<String>, + pub reason: Option<PreKeyFetchReason>, } impl PreKeyFetchSpec { @@ -129,10 +139,10 @@ impl PreKeyFetchSpec { Self { jids, reason: None } } - pub fn with_reason(jids: Vec<Jid>, reason: impl Into<String>) -> Self { + pub fn with_reason(jids: Vec<Jid>, reason: PreKeyFetchReason) -> Self { Self { jids, - reason: Some(reason.into()), + reason: Some(reason), } } } @@ -141,7 +151,10 @@ impl IqSpec for PreKeyFetchSpec { type Response = std::collections::HashMap<Jid, PreKeyBundle>; 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_ref().map(|r| r.as_str()), + ); InfoQuery::get( "encrypt", @@ -823,9 +836,9 @@ mod tests { #[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"); + let spec = PreKeyFetchSpec::with_reason(jids, PreKeyFetchReason::Retry); - assert_eq!(spec.reason, Some("retry".to_string())); + assert_eq!(spec.reason, Some(PreKeyFetchReason::Retry)); } #[test] diff --git a/wacore/src/iq/privacy.rs b/wacore/src/iq/privacy.rs index e2edfa3b0..9ef707f97 100644 --- a/wacore/src/iq/privacy.rs +++ b/wacore/src/iq/privacy.rs @@ -52,6 +52,7 @@ use crate::StringEnum; use crate::iq::spec::IqSpec; use crate::request::InfoQuery; +use crate::types::message::AddressingMode; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::{Jid, SERVER_JID}; use wacore_binary::node::{Node, NodeContent}; @@ -317,7 +318,7 @@ impl IqSpec for SetPrivacySettingSpec { .collect(); category_node = category_node.children(user_nodes); - privacy_node = privacy_node.attr("addressing_mode", "lid"); + privacy_node = privacy_node.attr("addressing_mode", AddressingMode::Lid.as_str()); } InfoQuery::set( diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index ce8cbf61c..1b1553e0c 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -143,7 +143,9 @@ pub fn parse_message_info( own_jid: &wacore_binary::jid::Jid, own_lid: Option<&wacore_binary::jid::Jid>, ) -> Result<crate::types::message::MessageInfo> { - use crate::types::message::{AddressingMode, EditAttribute, MessageInfo, MessageSource}; + use crate::types::message::{ + AddressingMode, EditAttribute, MessageCategory, MessageInfo, MessageSource, + }; use wacore_binary::jid::{self, JidExt as _}; let mut attrs = node.attrs(); @@ -219,7 +221,7 @@ pub fn parse_message_info( let category = attrs .optional_string("category") - .map(|s| s.to_string()) + .map(|s| MessageCategory::from(s.as_ref())) .unwrap_or_default(); let id = attrs.required_string("id")?.to_string(); diff --git a/wacore/src/stanza/receipt.rs b/wacore/src/stanza/receipt.rs index 18f48d3f1..51dbf1608 100644 --- a/wacore/src/stanza/receipt.rs +++ b/wacore/src/stanza/receipt.rs @@ -3,7 +3,7 @@ //! These functions contain no runtime dependencies (`self`, `Client`, spawn, sleep). //! Orchestration and dispatch remain in `whatsapp-rust/src/receipt.rs`. -use crate::types::message::MessageInfo; +use crate::types::message::{MessageCategory, MessageInfo}; use wacore_binary::jid::{JidExt as _, STATUS_BROADCAST_USER}; /// Determines whether a delivery receipt should be sent for this message. @@ -28,13 +28,13 @@ pub fn should_send_delivery_receipt(info: &MessageInfo) -> bool { // messages (category="peer"). These tell the primary phone that // this companion device received the message. // For all other messages, skip receipts for our own messages. - info.category == "peer" || !info.source.is_from_me + info.category == MessageCategory::Peer || !info.source.is_from_me } #[cfg(test)] mod tests { use super::*; - use crate::types::message::{MessageInfo, MessageSource}; + use crate::types::message::{MessageCategory, MessageInfo, MessageSource}; #[test] fn skip_empty_id() { @@ -107,7 +107,7 @@ mod tests { is_from_me: true, ..Default::default() }, - category: "peer".to_string(), + category: MessageCategory::Peer, ..Default::default() }; assert!(should_send_delivery_receipt(&info)); diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 30ce5cec8..6f12fde97 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use wacore_binary::jid::{Jid, JidExt, MessageId, MessageServerId}; use waproto::whatsapp as wa; +use crate::StringEnum; + /// Unique identifier for a message stanza within a chat. /// Used for deduplication and retry tracking. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -28,6 +30,23 @@ pub enum AddressingMode { Lid, } +#[derive(Debug, Clone, PartialEq, Eq, StringEnum)] +pub enum MessageCategory { + #[string_default] + #[str = ""] + Empty, + #[str = "peer"] + Peer, + #[string_fallback] + Other(String), +} + +impl serde::Serialize for MessageCategory { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Debug, Clone, Default, Serialize)] pub struct MessageSource { pub chat: Jid, @@ -117,7 +136,7 @@ pub struct MessageInfo { pub r#type: String, pub push_name: String, pub timestamp: DateTime<Utc>, - pub category: String, + pub category: MessageCategory, pub multicast: bool, pub media_type: String, pub edit: EditAttribute, diff --git a/wacore/src/types/user.rs b/wacore/src/types/user.rs index 1a35645b6..45f6acb53 100644 --- a/wacore/src/types/user.rs +++ b/wacore/src/types/user.rs @@ -1,7 +1,5 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use wacore_binary::jid::Jid; use waproto::whatsapp as wa; #[derive(Debug, Clone)] @@ -60,28 +58,3 @@ pub struct PrivacySettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub online: Option<PrivacySetting>, } - -#[derive(Debug, Clone)] -pub struct BusinessHoursConfig { - pub day_of_week: String, - pub mode: String, - pub open_time: String, - pub close_time: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Category { - pub id: String, - pub name: String, -} - -#[derive(Debug, Clone)] -pub struct BusinessProfile { - pub jid: Jid, - pub address: Option<String>, - pub email: Option<String>, - pub categories: Vec<Category>, - pub profile_options: HashMap<String, String>, - pub business_hours_time_zone: Option<String>, - pub business_hours: Vec<BusinessHoursConfig>, -}