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
46 changes: 46 additions & 0 deletions advanced/binary-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,52 @@ pub enum NackReason {

Each variant maps to the integer reason code WA Web sends on the wire. The client picks the variant from the decrypt failure path — `ParsingError` for malformed binary, `InvalidProtobuf` for wa::Message decode failures, `MaxRetryReached` after the PDO recovery state machine gives up, and so on. Consumers building custom transports can reuse the enum to produce wire-compatible nacks.

### Manual stanza acknowledgement

The automatic receive pipeline already acks and nacks stanzas as it processes them. Callers that intercept raw nodes themselves — custom transports, replay tooling, mock servers — can respond explicitly instead:

```rust
pub async fn acknowledge_stanza(
&self,
stanza: &NodeRef<'_>,
) -> Result<(), StanzaResponseError>

pub async fn reject_stanza(
&self,
stanza: &NodeRef<'_>,
rejection: StanzaRejection,
) -> Result<(), StanzaResponseError>
```

`acknowledge_stanza` sends a plain `<ack/>` built from the stanza's `id`/`from` (and, for `message` stanzas, the local device's PN — this fails with `StanzaResponseError::MissingLocalIdentity` if pairing hasn't completed). It always preserves the stanza's `participant` attribute, unlike the automatic pipeline, which omits a `participant` that merely duplicates the `from` JID on `<receipt>` stanzas.

`reject_stanza` sends `<ack error="…">` built from a `StanzaRejection`:

```rust
impl StanzaRejection {
pub const fn new(reason: NackReason) -> Self;
pub const fn invalid_protobuf(failure_reason: Option<i32>) -> Self;
pub const fn reason(self) -> NackReason;
pub const fn failure_reason(self) -> Option<i32>;
}
```

`invalid_protobuf` is the only constructor that can carry a `failure_reason` — the typed detail from a `wa::Message` decode failure; every other rejection reason encodes `None`.

`acknowledge_stanza` accepts any stanza class; `reject_stanza` accepts only `message`, `receipt`, and `notification` stanzas, unless `rejection.reason()` is `NackReason::UnrecognizedStanza`, in which case any class is accepted — matching the protocol's own catch-all nack path. Malformed input (missing `id`/`from`, or `reject_stanza` called on an unsupported class) is returned to the caller as a typed `StanzaResponseError` instead of being silently dropped, unlike the tolerant automatic receive path:
Comment thread
jlucaso1 marked this conversation as resolved.

```rust
pub enum StanzaResponseError {
MissingAttribute(&'static str),
MissingLocalIdentity,
UnsupportedStanzaClass,
Encoding(wacore_binary::error::BinaryError),
Client(ClientError),
}
```

See [Decryption retry mechanism](/guides/receiving-messages#requesting-a-retry-manually) for the equivalent manual entry point into retry receipts.

## Wire format examples

### Simple Message
Expand Down
2 changes: 2 additions & 0 deletions concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ The Meta AI bot's `msmsg` (`<enc type="msmsg">`) encryption type was the origina

**Ack SKDM-only session decrypts:** A `pkmsg`/`msg` that decrypts successfully but carries only a Sender Key Distribution Message (no user-facing content to dispatch) is now explicitly acked. Previously it could decrypt, skip event dispatch, and leave no ack — so the server kept replaying it from the offline queue. The fix closes that gap so SKDM-only stanzas drain like any other processed message.

**Nack unparseable message stanzas:** A `<message>` stanza whose required `id`/`from` attrs (and `participant`, for group/status messages) are missing or carry an invalid JID now gets an immediate `<ack error="487">` (`NackReason::ParsingError`) instead of a bare warning log and silent drop. `parse_message_info` itself became fail-fast for the same fields — it no longer falls back to a lenient default JID. Consumers driving their own ack/nack flow for intercepted stanzas can reach the same typed responses via `Client::acknowledge_stanza`/`reject_stanza` — see [Manual stanza acknowledgement](/advanced/binary-protocol#manual-stanza-acknowledgement).

### Critical app-state sync (pairing bootstrap)

Right after a fresh pairing (and on any reconnect before the account's critical app-state collections have synced), the client fetches the `CriticalBlock` and `CriticalUnblockLow` collections — blocked contacts and push name — via a batched IQ, before dispatching `Connected`. Decoding those snapshots requires the app-state **sync-key-share**, an E2E message the primary phone sends automatically, which can arrive late if a heavy history sync is saturating the stream at the same time.
Expand Down
35 changes: 34 additions & 1 deletion guides/receiving-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,8 @@ The library automatically:
5. Falls back to immediate PDO as last resort when retries are exhausted

```rust
// Retry reasons (internal, handled automatically)
// Retry reasons (handled automatically; also exported as `RetryReason`
// for callers driving retries manually, see below)
enum RetryReason {
NoSession = 1, // No session exists
InvalidKey = 2, // Invalid key
Expand All @@ -443,6 +444,38 @@ enum RetryReason {
}
```

#### Requesting a retry manually

`Client::request_message_retry` exposes the same retry-receipt path the automatic pipeline uses, for callers that intercept raw stanzas themselves (custom transports, replay tooling):

```rust
pub async fn request_message_retry(
self: &Arc<Self>,
stanza: &NodeRef<'_>,
options: RetryRequestOptions,
) -> Result<RetryRequestOutcome, RetryRequestError>
```

`stanza` must be a `<message>` node with `id` and `from` attrs, or the call fails fast with `RetryRequestError::UnsupportedStanzaClass`/`MissingAttribute` before any I/O. The client then parses the stanza once via the canonical message-info parser rather than reusing whatever metadata the caller already extracted — for a group or status-broadcast `from`, that parser also requires a valid `participant` attr, surfacing as `RetryRequestError::InvalidStanza` if it's missing or unparseable. `RetryRequestOptions` is a small builder:

```rust
RetryRequestOptions::new()
.with_reason(RetryReason::BadMac) // diagnostic reason sent to the sender, default UnknownError
.with_force_include_keys(true) // request the local key bundle even below the normal threshold
```

`RetryRequestOutcome` reports what happened without the caller needing to inspect internal counters:

- `Sent { retry_count, included_keys }` — the retry receipt reached the transport
- `Suppressed { retry_count }` — the protocol excludes this sender/chat combination from retry receipts, but the shared counter still advanced
- `LimitReached` — the shared retry counter had already hit its cap (5 attempts)

This call only sends the retry receipt; a transport ack (to clear the stanza from the server's offline queue) remains the caller's responsibility — see [Manual stanza acknowledgement](/advanced/binary-protocol#manual-stanza-acknowledgement).

<Note>
Key material is attached to a retry receipt only when `retry_count >= 2`, `force_include_keys` is explicitly set, or the destination is stateless/hosted — never on the strength of `reason` alone. The diagnostic `RetryReason` only affects what's reported to the sender, not whether keys go out.
</Note>

### Unavailable message recovery via PDO

When the server delivers a message with an `<unavailable>` child node instead of `<enc>` nodes, the message content is not present in the stanza. The client classifies the `<unavailable>` node into an `UnavailableType`:
Expand Down