Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Things that look correct and are not:
- **Locks.** `session_locks` serializes Signal encrypt/decrypt per protocol address; `chat_lanes` (`ChatLane::enqueue_lock` in `src/client.rs`) serializes *incoming* processing per chat. Outgoing sends are deliberately not per-chat locked — WA Web doesn't lock them either.
- **Wire-tagged enums.** Every protocol enum derives `WireEnum`, and its `#[wire = ...]` attribute is the single source of truth for the wire value. Do not also derive `serde::Serialize`/`Deserialize` or add `#[serde(rename_all)]` — the derive owns both. In tagged mode it generates a sibling `<Name>Tag`; parsers must dispatch on `<Name>Tag::try_from(node.tag.as_ref())` rather than string literals, so renaming a tag stays a one-attribute change. Modes and attributes: `agent_docs/protocol_architecture.md`.
- **Event payloads are a frozen API.** Sealed with `#[non_exhaustive]` + `#[derive(bon::Builder)]` and constructed via `Type::builder()…build()`; a maybe-absent field is `Option<T>`, never an empty-string or zero sentinel. The full stability policy is the `Event` doc comment in `wacore/src/types/events.rs`.
- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/src/types/wire_enums.rs`, `wacore/src/iq/targets.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. `wire_enums.rs` binds only the catalog entries listed in the emitter's `WANTED`, because 88 of the 403 have a synthetic name and names repeat across modules; the variants themselves always come from the bundle. A candidate is found by its variant set but decided by its module: two enums agreeing on every value are not the same enum unless the module owns the wire format we parse. `targets.rs` binds the same way and covers `w:g2` only, the one namespace where a request's target is not implied by its namespace.
- **Generated files are generated, not edited.** `wacore/src/iq/abprops.rs`, `wacore/src/iq/mex_operations.rs`, `wacore/appstate/src/schemas.rs`, `wacore/src/types/wire_enums.rs`, `wacore/src/iq/targets.rs`, `wacore/src/stanza/wire_tags.rs`, `wacore/binary/src/tokens.json`, `waproto/src/whatsapp.proto` and `wacore/src/version/generated.rs` all come out of `cargo run -p whatspec-codegen`, together, from one pinned whatspec commit. An action or flag the protocol carries but the bundle no longer builds goes in a hand-written sibling (`wacore/appstate/src/schemas_unlisted.rs`, `props::stale`), never in the generated file. `wire_enums.rs` binds only the catalog entries listed in the emitter's `WANTED`, because 88 of the 403 have a synthetic name and names repeat across modules; the variants themselves always come from the bundle. A candidate is found by its variant set but decided by its module: two enums agreeing on every value are not the same enum unless the module owns the wire format we parse. `targets.rs` binds the same way and covers `w:g2` only, the one namespace where a request's target is not implied by its namespace. `wire_tags.rs` takes its stanza tags from the union of the `notif`, `srvreq` and `stanza` documents, because the dispatcher table alone omits `iq` and `ack`, which this repository handles; it drops `privacy`, which is the type of an outgoing stanza and never arrives under that tag, so adding it would invite a handler that can never fire.
- **`whatsapp.proto` is not the whole persisted schema.** It comes from whatspec and is regenerated wholesale, so fields we persist but upstream does not declare live in `LOCAL_FIELDS` in `waproto/build.rs`, spliced into the descriptor at build time, and whole retained messages in `LOCAL_BLOCKS` in the codegen's proto emitter. Never hand-edit the `.proto` or `.desc` to add one — the next sync would drop it.
- **Blocking work** — `ureq`, heavy CPU — belongs in `tokio::task::spawn_blocking`; it shares a runtime with the read loop.
- **let-chains**, never nested `if let`. Clippy's `collapsible_if` is denied in CI.
Expand Down
12 changes: 7 additions & 5 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ use wacore_binary::Jid;
use portable_atomic::{AtomicI64, AtomicU64};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use wacore::stanza::wire_tags::{NotificationType, StanzaTag};

/// Lease that keeps decrypted-payload events enabled for one consumer.
///
Expand Down Expand Up @@ -1771,7 +1772,8 @@ fn ack_participant<'node, 'data>(
.filter(|participant| match policy {
AckParticipantPolicy::Preserve => true,
AckParticipantPolicy::OmitReceiptDestinationDuplicate => {
node.tag != "receipt" || !value_refs_display_equal(participant, from)
node.tag != StanzaTag::Receipt.as_str()
|| !value_refs_display_equal(participant, from)
}
})
}
Expand Down Expand Up @@ -1815,7 +1817,7 @@ fn encode_ack_bytes(
};

// WA Web stamps the own device JID for both classes.
let own_device_pn = if tag == "message" || tag == "status" {
let own_device_pn = if tag == StanzaTag::Message.as_str() || tag == StanzaTag::Status.as_str() {
Some(own_device_pn.ok_or(crate::features::StanzaResponseError::MissingLocalIdentity)?)
} else {
None
Expand Down Expand Up @@ -1948,7 +1950,7 @@ fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>
attrs.insert("class", NodeValue::from(tag));
attrs.insert("id", id);
attrs.insert("to", from);
if tag == "message"
if tag == StanzaTag::Message.as_str()
&& let Some(own_device_pn) = own_device_pn
{
attrs.insert("from", NodeValue::Jid(own_device_pn.clone()));
Expand All @@ -1971,10 +1973,10 @@ fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>

/// WA Web omits `type` when ACKing `<notification type="encrypt"><identity/></notification>`.
fn is_encrypt_identity_notification(node: &wacore_binary::NodeRef<'_>) -> bool {
node.tag == "notification"
node.tag == StanzaTag::Notification.as_str()
&& node
.get_attr("type")
.is_some_and(|value| value == "encrypt")
.is_some_and(|value| value == NotificationType::Encrypt.as_str())
&& node.get_optional_child("identity").is_some()
}

Expand Down
51 changes: 30 additions & 21 deletions src/client/node_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use super::*;
use crate::client::{PhashWaiter, ResponseWaiter};
use wacore::net::DisconnectReason;
use wacore::stanza::wire_tags::StanzaTag;

/// Non-error exits of [`Client::read_messages_loop`] — `ServerRecycle` keeps the
/// routine reconnect path out of `Err`, so severity consumers (logs, the span's
Expand Down Expand Up @@ -67,9 +68,9 @@ fn from_jid_matches(
/// reason.
fn is_connection_critical(node: &wacore_binary::NodeRef<'_>) -> bool {
matches!(
node.tag.as_ref(),
"success" | "failure" | "stream:error" | "ack"
) || (node.tag.as_ref() == "iq" && is_ping_request(node))
StanzaTag::try_from(node.tag.as_ref()),
Ok(StanzaTag::Success | StanzaTag::Failure | StanzaTag::StreamError | StanzaTag::Ack)
) || (node.tag.as_ref() == StanzaTag::Iq.as_str() && is_ping_request(node))
}

/// A server-initiated ping, which this client owes a pong.
Expand Down Expand Up @@ -352,7 +353,7 @@ impl Client {
) {
// ACKs need shared ownership only for opt-in raw/node observers. The
// usual response-waiter path borrows the node and can skip the Arc.
if node.tag() == "ack"
if node.tag() == StanzaTag::Ack.as_str()
&& !self.raw_node_forwarding_enabled()
&& self.node_waiter_count.load(Ordering::Acquire) == 0
&& !self.offline_sync_metrics.active.load(Ordering::Acquire)
Expand All @@ -378,7 +379,7 @@ impl Client {
let nr = node.get();

// --- Offline Sync Tracking ---
if nr.tag.as_ref() == "ib" {
if nr.tag.as_ref() == StanzaTag::InfoBanner.as_str() {
// Check for offline_preview child to get expected count
if let Some(preview) = nr.get_optional_child("offline_preview") {
let count: usize = preview
Expand Down Expand Up @@ -468,7 +469,7 @@ impl Client {
}
// --- End Tracking ---

if nr.tag.as_ref() == "iq"
if nr.tag.as_ref() == StanzaTag::Iq.as_str()
&& let Some(sync_node) = nr.get_optional_child("sync")
&& let Some(collection_node) = sync_node.get_optional_child("collection")
{
Expand All @@ -490,7 +491,7 @@ impl Client {
.dispatch(Event::RawNode(Arc::clone(&node)));
}

if nr.tag.as_ref() == "xmlstreamend" {
if nr.tag.as_ref() == StanzaTag::XmlStreamEnd.as_str() {
if self.expected_disconnect.load(Ordering::Relaxed) {
debug!("Received <xmlstreamend/>, expected disconnect.");
} else {
Expand All @@ -507,7 +508,7 @@ impl Client {
self.resolve_node_waiters(&node);
}

if nr.tag.as_ref() == "iq"
if nr.tag.as_ref() == StanzaTag::Iq.as_str()
&& let Some(id) = nr.get_attr("id").map(|v| v.as_str())
&& let Some(waiter) = self.response_waiters_guard().remove(id.as_ref())
{
Expand Down Expand Up @@ -575,13 +576,13 @@ impl Client {
// Bypass async_trait's boxed future for the hot built-in handlers while
// retaining router registration for direct router callers.
match nr.tag.as_ref() {
"ack" => {
t if t == StanzaTag::Ack.as_str() => {
self.handle_ack_response_arc(&node);
}
"receipt" => {
t if t == StanzaTag::Receipt.as_str() => {
self.handle_receipt_inline(node);
}
"message" => {
t if t == StanzaTag::Message.as_str() => {
crate::handlers::message::MessageHandler::handle_inline(
self.clone(),
node,
Expand All @@ -591,7 +592,7 @@ impl Client {
}
// Differs from a `<message>` only in tag, so WA Web retags it and
// runs the same pipeline.
"status" if is_status_broadcast_stanza(nr) => {
t if t == StanzaTag::Status.as_str() && is_status_broadcast_stanza(nr) => {
crate::handlers::message::MessageHandler::handle_inline(
self.clone(),
node,
Expand Down Expand Up @@ -646,18 +647,24 @@ impl Client {
/// enqueue could put a group message ahead of the pkmsg that establishes its
/// session. Acks and receipts qualify only while nothing observes them.
pub(crate) fn processes_inline(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
match node.tag.as_ref() {
"success" | "failure" | "stream:error" | "message" | "ib" => true,
"status" => is_status_broadcast_stanza(node),
"receipt" => {
match StanzaTag::try_from(node.tag.as_ref()) {
Comment thread
jlucaso1 marked this conversation as resolved.
Ok(
StanzaTag::Success
| StanzaTag::Failure
| StanzaTag::StreamError
| StanzaTag::Message
| StanzaTag::InfoBanner,
) => true,
Ok(StanzaTag::Status) => is_status_broadcast_stanza(node),
Ok(StanzaTag::Receipt) => {
!self.synchronous_ack
&& !self.raw_node_forwarding_enabled()
&& !self
.core
.event_bus
.has_handler_for(wacore::types::events::EventKind::Receipt)
}
"ack" => {
Ok(StanzaTag::Ack) => {
!self.raw_node_forwarding_enabled()
&& !self
.core
Expand Down Expand Up @@ -696,17 +703,19 @@ impl Client {
/// would redeliver indefinitely. WA Web emits `<receipt context="status">`
/// in the success path on top of this; the duplicate is tolerated.
pub(crate) fn should_ack(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
let tag = node.tag.as_ref();
let tag = StanzaTag::try_from(node.tag.as_ref());
if node.get_attr("id").is_none() {
return false;
}
if node.get_attr("from").is_none() {
return false;
}
match tag {
"receipt" | "notification" | "call" => true,
"message" => from_jid_matches(node, |j| j.is_newsletter() || j.is_status_broadcast()),
"status" => is_status_broadcast_stanza(node),
Ok(StanzaTag::Receipt | StanzaTag::Notification | StanzaTag::Call) => true,
Ok(StanzaTag::Message) => {
from_jid_matches(node, |j| j.is_newsletter() || j.is_status_broadcast())
}
Ok(StanzaTag::Status) => is_status_broadcast_stanza(node),
_ => false,
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/features/media_reupload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub use wacore::media_retry::MediaRetryResult;
use wacore::media_retry::{
build_media_retry_receipt, encrypt_media_retry_receipt, parse_media_retry_notification,
};
use wacore::stanza::wire_tags::{NotificationType, StanzaTag};
use wacore_binary::{Jid, JidExt as _};

const MEDIA_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
Expand Down Expand Up @@ -109,8 +110,8 @@ impl<'a> MediaReupload<'a> {

// Register waiter BEFORE sending (to avoid race)
let waiter = self.client.wait_for_node(
NodeFilter::tag("notification")
.attr("type", "mediaretry")
NodeFilter::tag(StanzaTag::Notification.as_str())
.attr("type", NotificationType::MediaRetry.as_str())
.attr("id", req.msg_id),
);

Expand Down
9 changes: 5 additions & 4 deletions src/handlers/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::traits::StanzaHandler;
use crate::client::Client;
use async_trait::async_trait;
use std::sync::Arc;
use wacore::stanza::wire_tags::StanzaTag;
use wacore_binary::OwnedNodeRef;

/// Handler for `<success>` stanzas.
Expand All @@ -12,7 +13,7 @@ pub struct SuccessHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for SuccessHandler {
fn tag(&self) -> &'static str {
"success"
StanzaTag::Success.as_str()
}

#[cfg_attr(
Expand All @@ -38,7 +39,7 @@ pub struct FailureHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for FailureHandler {
fn tag(&self) -> &'static str {
"failure"
StanzaTag::Failure.as_str()
}

#[cfg_attr(
Expand All @@ -64,7 +65,7 @@ pub struct StreamErrorHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for StreamErrorHandler {
fn tag(&self) -> &'static str {
"stream:error"
StanzaTag::StreamError.as_str()
Comment thread
jlucaso1 marked this conversation as resolved.
}

#[cfg_attr(
Expand All @@ -90,7 +91,7 @@ pub struct AckHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for AckHandler {
fn tag(&self) -> &'static str {
"ack"
StanzaTag::Ack.as_str()
}

async fn handle(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use crate::client::CallError;
use crate::client::Client;

use super::traits::StanzaHandler;
use wacore::stanza::wire_tags::StanzaTag;

/// Router sends the generic `<ack>` via `should_ack`, so this handler only
/// parses and dispatches. On `Offer` it also emits the `<receipt><offer/></receipt>`
Expand All @@ -46,7 +47,7 @@ pub struct CallHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for CallHandler {
fn tag(&self) -> &'static str {
"call"
StanzaTag::Call.as_str()
}

#[cfg_attr(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/chatstate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::sync::Arc;
use wacore::iq::chatstate::{
ChatstateParseError, ChatstateSource, ChatstateStanza, ReceivedChatState,
};
use wacore::stanza::wire_tags::StanzaTag;
use wacore_binary::Jid;

/// Event for incoming chatstate (`<chatstate/>`) stanzas.
Expand Down Expand Up @@ -50,7 +51,7 @@ pub struct ChatstateHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for ChatstateHandler {
fn tag(&self) -> &'static str {
"chatstate"
StanzaTag::ChatState.as_str()
}

#[cfg_attr(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/ib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use log::{debug, info, warn};
use std::sync::Arc;
use wacore::appstate::patch_decode::WAPatchName;
use wacore::iq::dirty::{DirtyBit, DirtyType};
use wacore::stanza::wire_tags::StanzaTag;

/// Handler for `<ib>` (information broadcast) stanzas.
///
Expand All @@ -22,7 +23,7 @@ pub struct IbHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for IbHandler {
fn tag(&self) -> &'static str {
"ib"
StanzaTag::InfoBanner.as_str()
}

async fn handle(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/iq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::client::Client;
use async_trait::async_trait;
use log::{debug, warn};
use std::sync::Arc;
use wacore::stanza::wire_tags::StanzaTag;
use wacore::xml::DisplayableNodeRef;

/// Handler for `<iq>` (Info/Query) stanzas.
Expand All @@ -19,7 +20,7 @@ pub struct IqHandler;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for IqHandler {
fn tag(&self) -> &'static str {
"iq"
StanzaTag::Iq.as_str()
}

#[cfg_attr(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::client::{ChatLane, Client, QueuedChatMessage};
use async_trait::async_trait;
use log::warn;
use std::sync::Arc;
use wacore::stanza::wire_tags::StanzaTag;

/// WA Web: `WAWebMessageQueue` uses `promiseTimeout(r(), 2e4)` per queued handler.
const MAX_MESSAGE_DELAY_MS: u64 = 20_000;
Expand Down Expand Up @@ -59,7 +60,7 @@ impl MessageHandler {
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl StanzaHandler for MessageHandler {
fn tag(&self) -> &'static str {
"message"
StanzaTag::Message.as_str()
}

async fn handle(
Expand Down
Loading
Loading