Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
57 changes: 36 additions & 21 deletions src/handlers/notification/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use crate::types::events::Event;
use async_trait::async_trait;
use log::debug;
use std::sync::Arc;
use wacore::stanza::wire_tags::NotificationType;
use wacore::stanza::wire_tags::StanzaTag;
use wacore_binary::OwnedNodeRef;

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

async fn handle(
Expand All @@ -44,37 +46,50 @@ async fn handle_notification_impl(client: &Arc<Client>, node: Arc<OwnedNodeRef>)
let nr = node.get();
let notification_type = nr.attrs().optional_string("type");

match notification_type.as_deref().unwrap_or_default() {
"encrypt" => handle_encrypt_notification(client, nr).await,
"server_sync" => handle_server_sync_notification(client, nr),
"account_sync" => handle_account_sync_notification(client, nr).await,
"devices" => handle_devices_notification(client, nr).await,
"link_code_companion_reg" => {
let parsed = notification_type
.as_deref()
.and_then(|t| NotificationType::try_from(t).ok());

match parsed {
Some(NotificationType::Encrypt) => handle_encrypt_notification(client, nr).await,
Some(NotificationType::ServerSync) => handle_server_sync_notification(client, nr),
Some(NotificationType::AccountSync) => handle_account_sync_notification(client, nr).await,
Some(NotificationType::Devices) => handle_devices_notification(client, nr).await,
Some(NotificationType::LinkCodeCompanionReg) => {
crate::pair_code::handle_pair_code_notification(client, nr).await;
}
"companion_reg_refresh" => handle_companion_reg_refresh(client, nr).await,
"business" => handle_business_notification(client, nr).await,
"picture" => handle_picture_notification(client, nr),
"privacy_token" => handle_privacy_token_notification(client, nr).await,
"status" => handle_status_notification(client, nr),
"contacts" => handle_contacts_notification(client, nr).await,
"w:gp2" => handle_group_notification(client, Arc::clone(&node)).await,
"disappearing_mode" => handle_disappearing_mode_notification(client, nr),
"newsletter" => handle_newsletter_notification(client, Arc::clone(&node)),
"mex" => handle_mex_notification(client, nr),
crate::passkey::flow::NOTIF_PASSKEY_REQUEST => {
Some(NotificationType::CompanionRegRefresh) => {
handle_companion_reg_refresh(client, nr).await
}
Some(NotificationType::Business) => handle_business_notification(client, nr).await,
Some(NotificationType::Picture) => handle_picture_notification(client, nr),
Some(NotificationType::PrivacyToken) => handle_privacy_token_notification(client, nr).await,
Some(NotificationType::Status) => handle_status_notification(client, nr),
Some(NotificationType::Contacts) => handle_contacts_notification(client, nr).await,
Some(NotificationType::WGp2) => handle_group_notification(client, Arc::clone(&node)).await,
Some(NotificationType::DisappearingMode) => {
handle_disappearing_mode_notification(client, nr)
}
Some(NotificationType::Newsletter) => {
handle_newsletter_notification(client, Arc::clone(&node))
}
Some(NotificationType::Mex) => handle_mex_notification(client, nr),
Some(NotificationType::PasskeyPrologueRequest) => {
crate::passkey::flow::handle_passkey_notification(client, Arc::clone(&node)).await;
}
crate::passkey::flow::NOTIF_PASSKEY_CONTINUATION => {
Some(NotificationType::CrscContinuation) => {
crate::passkey::flow::handle_passkey_continuation(client, Arc::clone(&node)).await;
}
"mediaretry" => {
Some(NotificationType::MediaRetry) => {
debug!(
"Received mediaretry notification for msg {}",
nr.attrs().optional_string("id").unwrap_or_default()
);
}
other => {
// A type the protocol carries that this client does not act on, or one
// it does not model at all. Both reach the consumer as a raw event.
_ => {
let other = notification_type.as_deref().unwrap_or_default();
debug!("Unhandled notification type '{other}', dispatching raw event");
client
.core
Expand Down
Loading
Loading