diff --git a/src/client.rs b/src/client.rs
index 851e1fdd7..04358902c 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -724,6 +724,9 @@ impl Client {
/// Build and send an node corresponding to the given stanza.
async fn send_ack_for(&self, node: &Node) -> Result<(), ClientError> {
+ if !self.is_connected() || self.expected_disconnect.load(Ordering::Relaxed) {
+ return Ok(());
+ }
let id = match node.attrs.get("id") {
Some(v) => v.clone(),
None => return Ok(()),
@@ -1471,49 +1474,81 @@ impl Client {
.map(|n| n.attrs().optional_string("type").unwrap_or("").to_string())
.unwrap_or_default();
- match (code, conflict_type.as_str()) {
- ("515", _) => {
- // 515 is expected during registration/pairing phase - server closes stream after pairing
- info!(target: "Client", "Got 515 stream error, server is closing stream (expected after pairing). Will auto-reconnect.");
- self.expect_disconnect().await;
- // Proactively disconnect transport since server may not close the connection
- // Clone the transport Arc before spawning to avoid holding the lock
- let transport_opt = self.transport.lock().await.clone();
- if let Some(transport) = transport_opt {
- // Spawn disconnect in background so we don't block the message loop
- tokio::spawn(async move {
- info!(target: "Client", "Disconnecting transport after 515");
- transport.disconnect().await;
- });
- }
- }
- ("401", "device_removed") | (_, "replaced") => {
- info!(target: "Client", "Got stream error indicating client was removed or replaced. Logging out.");
- self.expect_disconnect().await;
- self.enable_auto_reconnect.store(false, Ordering::Relaxed);
+ if !conflict_type.is_empty() {
+ info!(
+ target: "Client",
+ "Got stream error indicating client was removed or replaced (conflict={}). Logging out.",
+ conflict_type
+ );
+ self.expect_disconnect().await;
+ self.enable_auto_reconnect.store(false, Ordering::Relaxed);
- let event = if conflict_type == "replaced" {
- Event::StreamReplaced(crate::types::events::StreamReplaced)
- } else {
- Event::LoggedOut(crate::types::events::LoggedOut {
- on_connect: false,
- reason: ConnectFailureReason::LoggedOut,
- })
- };
- self.core.event_bus.dispatch(&event);
- }
- ("503", _) => {
- info!(target: "Client", "Got 503 service unavailable, will auto-reconnect.");
+ let event = if conflict_type == "replaced" {
+ Event::StreamReplaced(crate::types::events::StreamReplaced)
+ } else {
+ Event::LoggedOut(crate::types::events::LoggedOut {
+ on_connect: false,
+ reason: ConnectFailureReason::LoggedOut,
+ })
+ };
+ self.core.event_bus.dispatch(&event);
+
+ let transport_opt = self.transport.lock().await.clone();
+ if let Some(transport) = transport_opt {
+ tokio::spawn(async move {
+ info!(target: "Client", "Disconnecting transport after conflict");
+ transport.disconnect().await;
+ });
}
- _ => {
- error!(target: "Client", "Unknown stream error: {}", DisplayableNode(node));
- self.expect_disconnect().await;
- self.core.event_bus.dispatch(&Event::StreamError(
- crate::types::events::StreamError {
- code: code.to_string(),
- raw: Some(node.clone()),
- },
- ));
+ } else {
+ match code {
+ "515" => {
+ // 515 is expected during registration/pairing phase - server closes stream after pairing
+ info!(target: "Client", "Got 515 stream error, server is closing stream (expected after pairing). Will auto-reconnect.");
+ self.expect_disconnect().await;
+ // Proactively disconnect transport since server may not close the connection
+ // Clone the transport Arc before spawning to avoid holding the lock
+ let transport_opt = self.transport.lock().await.clone();
+ if let Some(transport) = transport_opt {
+ // Spawn disconnect in background so we don't block the message loop
+ tokio::spawn(async move {
+ info!(target: "Client", "Disconnecting transport after 515");
+ transport.disconnect().await;
+ });
+ }
+ }
+ "516" => {
+ info!(target: "Client", "Got 516 stream error (device removed). Logging out.");
+ self.expect_disconnect().await;
+ self.enable_auto_reconnect.store(false, Ordering::Relaxed);
+ self.core.event_bus.dispatch(&Event::LoggedOut(
+ crate::types::events::LoggedOut {
+ on_connect: false,
+ reason: ConnectFailureReason::LoggedOut,
+ },
+ ));
+
+ let transport_opt = self.transport.lock().await.clone();
+ if let Some(transport) = transport_opt {
+ tokio::spawn(async move {
+ info!(target: "Client", "Disconnecting transport after 516");
+ transport.disconnect().await;
+ });
+ }
+ }
+ "503" => {
+ info!(target: "Client", "Got 503 service unavailable, will auto-reconnect.");
+ }
+ _ => {
+ error!(target: "Client", "Unknown stream error: {}", DisplayableNode(node));
+ self.expect_disconnect().await;
+ self.core.event_bus.dispatch(&Event::StreamError(
+ crate::types::events::StreamError {
+ code: code.to_string(),
+ raw: Some(node.clone()),
+ },
+ ));
+ }
}
}
diff --git a/src/handlers/message.rs b/src/handlers/message.rs
index 6cabed3d3..232b6c722 100644
--- a/src/handlers/message.rs
+++ b/src/handlers/message.rs
@@ -30,12 +30,8 @@ impl StanzaHandler for MessageHandler {
// 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 = match node.attrs().optional_string("from") {
- Some(id) if !id.is_empty() => id.to_string(),
- Some(_) => {
- warn!("Message stanza has empty 'from' attribute");
- return false;
- }
+ let chat_id = match node.attrs().optional_jid("from") {
+ Some(jid) => jid.to_string(),
None => {
warn!("Message stanza missing required 'from' attribute");
return false;
diff --git a/src/pair.rs b/src/pair.rs
index 170daf663..fe5d305cb 100644
--- a/src/pair.rs
+++ b/src/pair.rs
@@ -25,12 +25,11 @@ pub fn make_qr_data(store: &crate::store::Device, ref_str: String) -> String {
pub async fn handle_iq(client: &Arc, node: &Node) -> bool {
// Server JID is "s.whatsapp.net" (no @ prefix for server-only JIDs)
- if node
+ if !node
.attrs
.get("from")
- .and_then(|s| s.as_str())
- .unwrap_or_default()
- != SERVER_JID
+ .map(|from| from == SERVER_JID)
+ .unwrap_or(false)
{
return false;
}
diff --git a/src/retry.rs b/src/retry.rs
index 405abb9f6..759738df8 100644
--- a/src/retry.rs
+++ b/src/retry.rs
@@ -6,11 +6,13 @@ use prost::Message;
use rand::TryRngCore;
use scopeguard;
use std::sync::Arc;
+use wacore::iq::prekeys::{OneTimePreKeyNode, SignedPreKeyNode};
use wacore::libsignal::protocol::{
KeyPair, PreKeyBundle, PublicKey, UsePQRatchet, process_prekey_bundle,
};
use wacore::libsignal::store::PreKeyStore;
use wacore::libsignal::store::SessionStore;
+use wacore::protocol::ProtocolNode;
use wacore::types::jid::JidExt;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::jid::JidExt as _;
@@ -419,51 +421,27 @@ impl Client {
let identity_key = PublicKey::from_djb_public_key_bytes(identity_bytes)?;
// Extract prekey (optional in some cases).
- let prekey_data = keys_node.get_optional_child("key").and_then(|key_node| {
- let id_bytes = key_node
- .get_optional_child("id")
- .and_then(get_bytes_content)?;
- let value_bytes = key_node
- .get_optional_child("value")
- .and_then(get_bytes_content)?;
-
- // PreKey ID is 3 bytes big-endian.
- let prekey_id = if id_bytes.len() >= 3 {
- u32::from_be_bytes([0, id_bytes[0], id_bytes[1], id_bytes[2]])
- } else {
- return None;
- };
-
- let prekey_public = PublicKey::from_djb_public_key_bytes(value_bytes).ok()?;
- Some((prekey_id.into(), prekey_public))
- });
+ let prekey_node = keys_node
+ .get_optional_child("key")
+ .map(OneTimePreKeyNode::try_from_node)
+ .transpose()?;
+ let prekey_data = if let Some(prekey_node) = prekey_node {
+ let prekey_public = PublicKey::from_djb_public_key_bytes(&prekey_node.public_bytes)?;
+ Some((prekey_node.id.into(), prekey_public))
+ } else {
+ None
+ };
// Extract signed prekey.
let skey_node = keys_node
.get_optional_child("skey")
.ok_or_else(|| anyhow::anyhow!("Missing signed prekey in retry receipt"))?;
- let skey_id_bytes = skey_node
- .get_optional_child("id")
- .and_then(get_bytes_content)
- .ok_or_else(|| anyhow::anyhow!("Missing signed prekey ID"))?;
- let skey_id = if skey_id_bytes.len() >= 3 {
- u32::from_be_bytes([0, skey_id_bytes[0], skey_id_bytes[1], skey_id_bytes[2]])
- } else {
- return Err(anyhow::anyhow!("Invalid signed prekey ID length"));
- };
-
- let skey_value_bytes = skey_node
- .get_optional_child("value")
- .and_then(get_bytes_content)
- .ok_or_else(|| anyhow::anyhow!("Missing signed prekey value"))?;
- let skey_public = PublicKey::from_djb_public_key_bytes(skey_value_bytes)?;
-
- let skey_sig_bytes = skey_node
- .get_optional_child("signature")
- .and_then(get_bytes_content)
- .ok_or_else(|| anyhow::anyhow!("Missing signed prekey signature"))?;
- let skey_signature: [u8; 64] = skey_sig_bytes
+ let signed_prekey = SignedPreKeyNode::try_from_node(skey_node)?;
+ let skey_public = PublicKey::from_djb_public_key_bytes(&signed_prekey.public_bytes)?;
+ let skey_signature: [u8; 64] = signed_prekey
+ .signature
+ .as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid signature length"))?;
@@ -472,7 +450,7 @@ impl Client {
registration_id,
u32::from(requester_jid.device).into(),
prekey_data,
- skey_id.into(),
+ signed_prekey.id.into(),
skey_public,
skey_signature.into(),
identity_key.into(),
@@ -588,10 +566,9 @@ impl Client {
.public_key_bytes()
.to_vec();
- let prekey_id_bytes = new_prekey_id.to_be_bytes()[1..].to_vec();
let prekey_value_bytes = new_prekey_keypair.public_key.public_key_bytes().to_vec();
- let skey_id_bytes = 1u32.to_be_bytes()[1..].to_vec();
+ let skey_id = device_snapshot.signed_pre_key_id;
let skey_value_bytes = device_snapshot
.signed_pre_key
.public_key
@@ -614,19 +591,9 @@ impl Client {
NodeBuilder::new("identity")
.bytes(identity_key_bytes)
.build(),
- NodeBuilder::new("key")
- .children([
- NodeBuilder::new("id").bytes(prekey_id_bytes).build(),
- NodeBuilder::new("value").bytes(prekey_value_bytes).build(),
- ])
- .build(),
- NodeBuilder::new("skey")
- .children([
- NodeBuilder::new("id").bytes(skey_id_bytes).build(),
- NodeBuilder::new("value").bytes(skey_value_bytes).build(),
- NodeBuilder::new("signature").bytes(skey_sig_bytes).build(),
- ])
- .build(),
+ OneTimePreKeyNode::new(new_prekey_id, prekey_value_bytes).into_node(),
+ SignedPreKeyNode::new(skey_id, skey_value_bytes, skey_sig_bytes)
+ .into_node(),
NodeBuilder::new("device-identity")
.bytes(device_identity_bytes)
.build(),
diff --git a/wacore/derive/src/lib.rs b/wacore/derive/src/lib.rs
index a592b8d4e..d5452775e 100644
--- a/wacore/derive/src/lib.rs
+++ b/wacore/derive/src/lib.rs
@@ -36,17 +36,29 @@ use syn::{Data, DeriveInput, Fields, parse_macro_input};
/// # Attributes
///
/// - `#[protocol(tag = "tagname")]` - Required. Specifies the XML tag name.
-/// - `#[attr(name = "attrname")]` - Marks a field as an XML attribute.
+/// - `#[attr(name = "attrname")]` - Marks a String field as an XML attribute.
/// - `#[attr(name = "attrname", default = "value")]` - Attribute with default value.
+/// For `Option` fields, a default always yields `Some(default)`.
+/// - `#[attr(name = "attrname", jid)]` - Marks a Jid field as a JID attribute (required).
+/// - `#[attr(name = "attrname", jid, optional)]` - Marks an Option field as optional.
///
/// # Example
///
/// ```ignore
/// #[derive(ProtocolNode)]
-/// #[protocol(tag = "query")]
-/// pub struct QueryRequest {
-/// #[attr(name = "request", default = "interactive")]
-/// pub request_type: String,
+/// #[protocol(tag = "message")]
+/// pub struct MessageStanza {
+/// #[attr(name = "from", jid)]
+/// pub from: Jid,
+///
+/// #[attr(name = "to", jid)]
+/// pub to: Jid,
+///
+/// #[attr(name = "id")]
+/// pub id: String,
+///
+/// #[attr(name = "sender_lid", jid, optional)]
+/// pub sender_lid: Option,
/// }
/// ```
#[proc_macro_derive(ProtocolNode, attributes(protocol, attr))]
@@ -105,8 +117,36 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream {
.map(|info| {
let field_ident = &info.field_ident;
let attr_name = &info.attr_name;
- quote! {
- .attr(#attr_name, self.#field_ident.to_string())
+
+ match (&info.attr_type, info.optional) {
+ (AttrType::Jid, true) => {
+ // Option - only insert if Some
+ quote! {
+ if let Some(jid) = self.#field_ident {
+ builder = builder.jid_attr(#attr_name, jid);
+ }
+ }
+ }
+ (AttrType::Jid, false) => {
+ // Required Jid - always insert
+ quote! {
+ builder = builder.jid_attr(#attr_name, self.#field_ident);
+ }
+ }
+ (AttrType::String, true) => {
+ // Option - only insert if Some
+ quote! {
+ if let Some(s) = self.#field_ident {
+ builder = builder.attr(#attr_name, s);
+ }
+ }
+ }
+ (AttrType::String, false) => {
+ // Required String - always insert
+ quote! {
+ builder = builder.attr(#attr_name, self.#field_ident);
+ }
+ }
}
})
.collect();
@@ -116,32 +156,71 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream {
.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())
+
+ match (&info.attr_type, info.optional, &info.default) {
+ (AttrType::Jid, false, _) => {
+ // Required Jid
+ quote! {
+ #field_ident: node.attrs().optional_jid(#attr_name)
+ .ok_or_else(|| ::anyhow::anyhow!("missing required attribute '{}'", #attr_name))?
+ }
+ }
+ (AttrType::Jid, true, _) => {
+ // Optional Jid
+ quote! {
+ #field_ident: node.attrs().optional_jid(#attr_name)
+ }
+ }
+ (AttrType::String, false, Some(default)) => {
+ // String with 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().optional_string(#attr_name)
- .ok_or_else(|| ::anyhow::anyhow!("missing required attribute '{}'", #attr_name))?
- .to_string()
+ (AttrType::String, false, None) => {
+ // Required String
+ quote! {
+ #field_ident: node.attrs().required_string(#attr_name)?.to_string()
+ }
+ }
+ (AttrType::String, true, Some(default)) => {
+ // Optional String with default (always Some)
+ quote! {
+ #field_ident: node.attrs().optional_string(#attr_name)
+ .map(|s| s.to_string())
+ .or_else(|| Some(#default.to_string()))
+ }
+ }
+ (AttrType::String, true, None) => {
+ // Optional String
+ quote! {
+ #field_ident: node.attrs().optional_string(#attr_name).map(|s| s.to_string())
+ }
}
}
})
.collect();
- // Only generate Default impl if all fields have defaults
- let all_have_defaults = attr_fields.iter().all(|info| info.default.is_some());
+ // Only generate Default impl if all fields have defaults or are optional
+ let all_have_defaults = attr_fields
+ .iter()
+ .all(|info| info.default.is_some() || info.optional);
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() }
+ match (&info.attr_type, info.optional, &info.default) {
+ (_, true, Some(default)) => quote! { #field_ident: Some(#default.to_string()) },
+ (_, true, None) => quote! { #field_ident: None },
+ (AttrType::String, false, Some(default)) => {
+ quote! { #field_ident: #default.to_string() }
+ }
+ _ => unreachable!("all_have_defaults check should prevent this branch"),
+ }
})
.collect();
@@ -165,9 +244,9 @@ pub fn derive_protocol_node(input: TokenStream) -> TokenStream {
}
fn into_node(self) -> ::wacore_binary::node::Node {
- ::wacore_binary::builder::NodeBuilder::new(#tag)
- #(#attr_setters)*
- .build()
+ let mut builder = ::wacore_binary::builder::NodeBuilder::new(#tag);
+ #(#attr_setters)*
+ builder.build()
}
fn try_from_node(node: &::wacore_binary::node::Node) -> ::anyhow::Result {
@@ -248,9 +327,16 @@ fn generate_empty_impl(name: &syn::Ident, tag: &str) -> proc_macro2::TokenStream
}
}
+enum AttrType {
+ String,
+ Jid,
+}
+
struct AttrFieldInfo {
field_ident: syn::Ident,
attr_name: String,
+ attr_type: AttrType,
+ optional: bool,
default: Option,
}
@@ -279,10 +365,15 @@ fn extract_attr_info(field: &syn::Field) -> Result