Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
61 changes: 40 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,54 @@ 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" => {
// Dispatching on the generated vocabulary rather than on string literals:
// a type renamed upstream then fails to parse here and lands in the raw
// event arm, instead of leaving a literal that still compiles, still reads
// correctly, and never matches again.
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
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
3 changes: 2 additions & 1 deletion src/handlers/presence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::client::Client;
use async_trait::async_trait;
use log::debug;
use std::sync::Arc;
use wacore::stanza::wire_tags::StanzaTag;
use wacore::types::events::{Event, PresenceUpdate};

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

#[cfg_attr(
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/receipt.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;

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

async fn handle(
Expand Down
22 changes: 14 additions & 8 deletions src/passkey/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Jid, Node, NodeContent, NodeRef, OwnedNodeRef, SERVER_JID, Server};
use waproto::whatsapp as wa;

/// `<notification type=...>` routing keys, consumed by the notification dispatcher.
pub(crate) const NOTIF_PASSKEY_REQUEST: &str = "passkey_prologue_request";
pub(crate) const NOTIF_PASSKEY_CONTINUATION: &str = "crsc_continuation";

const MD_NAMESPACE: &str = "md";
const TAG_REF: &str = "ref";
const TAG_PASSKEY_REQUEST_OPTIONS: &str = "passkey_request_options";
Expand Down Expand Up @@ -618,6 +614,7 @@ mod tests {
use std::sync::Mutex;
use std::time::Duration;
use wacore::libsignal::protocol::PublicKey;
use wacore::stanza::wire_tags::NotificationType;
use waproto::whatsapp as wa;

fn server_notification(notif_type: &'static str, child: Option<Node>) -> Arc<OwnedNodeRef> {
Expand Down Expand Up @@ -895,7 +892,10 @@ mod tests {
.bytes(options.as_bytes().to_vec())
.build();
client
.process_node(server_notification(NOTIF_PASSKEY_REQUEST, Some(child)))
.process_node(server_notification(
NotificationType::PasskeyPrologueRequest.as_str(),
Some(child),
))
.await;

// The rotation is deferred to confirmation, so the stored secret is unchanged.
Expand Down Expand Up @@ -928,7 +928,7 @@ mod tests {
.bytes(b"{}".to_vec())
.build();
let node = NodeBuilder::new("notification")
.attr("type", NOTIF_PASSKEY_REQUEST)
.attr("type", NotificationType::PasskeyPrologueRequest.as_str())
.attr("from", "12345@s.whatsapp.net")
.children([child])
.build();
Expand All @@ -954,7 +954,10 @@ mod tests {
// No inline options: the handler falls back to an IQ fetch. The test client
// isn't connected, so the fetch fails and surfaces a non-continuation error.
client
.process_node(server_notification(NOTIF_PASSKEY_REQUEST, None))
.process_node(server_notification(
NotificationType::PasskeyPrologueRequest.as_str(),
None,
))
.await;

wait_for(&collector, |e| {
Expand Down Expand Up @@ -983,7 +986,10 @@ mod tests {
.bytes(buffa::Message::encode_to_vec(&primary))
.build();
client
.process_node(server_notification(NOTIF_PASSKEY_CONTINUATION, Some(child)))
.process_node(server_notification(
NotificationType::CrscContinuation.as_str(),
Some(child),
))
.await;

wait_for(
Expand Down
11 changes: 4 additions & 7 deletions tools/whatspec-codegen/src/emit/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,10 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result<String> {
out.push_str(" #[wire_default]\n");
marked += 1;
}
out.push_str(&format!(" #[wire = {}]\n {ident},\n", rust_str(wire)));
out.push_str(&format!(
" #[wire = {}]\n {ident},\n",
super::rust_str(wire)
));
}

// Checked against what was emitted rather than against the catalog, so the
Expand Down Expand Up @@ -371,12 +374,6 @@ fn masks(wanted: &Wanted, def: &EnumDef) -> Result<String> {
Ok(out)
}

/// A Rust string literal for a wire value. Wire values are ASCII identifiers in
/// practice, so this only has to survive a quote or a backslash appearing.
fn rust_str(s: &str) -> String {
format!("{s:?}")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
7 changes: 7 additions & 0 deletions tools/whatspec-codegen/src/emit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod appstate;
pub mod enums;
pub mod iq_targets;
pub mod mex;
pub mod notif;
pub mod proto;
pub mod tokens;
pub mod version;
Expand All @@ -18,6 +19,12 @@ pub fn header(what: &str, wa_version: &str) -> String {
format!("//! Auto-generated {what} (WhatsApp {wa_version}). DO NOT EDIT.\n//!\n")
}

/// A Rust string literal for a wire value. Wire values are ASCII identifiers in
/// practice, so this only has to survive a quote or a backslash appearing.
pub fn rust_str(s: &str) -> String {
format!("{s:?}")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading