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
39 changes: 39 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2065,6 +2065,45 @@ while let Ok(event) = event_rx.recv().await {
// Drop `_lease` to stop forwarding.
```

### acquire_enc_decrypt_failed_forwarding

```rust
pub fn acquire_enc_decrypt_failed_forwarding(self: &Arc<Self>) -> EncDecryptFailedLease
```

Acquire a lease that keeps [`Event::EncDecryptFailed`](/concepts/events#encdecryptfailed) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::EncDecryptFailed` added back in, or it won't see the event even while a lease is held. This is the failing counterpart of [`acquire_decrypted_payload_forwarding`](#acquire_decrypted_payload_forwarding) — same per-`<enc>` granularity, same `enc_index` numbering — but tracked by a separate counter on purpose: a consumer that wants both halves of a stanza's decryption holds both leases, one that wants only failures does not make the success path clone plaintext, and one that wants only successes pays nothing extra on the failure paths.

<ResponseField name="EncDecryptFailedLease" type="EncDecryptFailedLease">
RAII lease. `Event::EncDecryptFailed` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
</ResponseField>

<Note>
While no lease is held, nothing is emitted and nothing is built — each failure branch costs one relaxed atomic load.
Comment on lines +2077 to +2084

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use direct reader guidance and split the contract details.

Lines 2074-2081 use third-person consumers. They also combine activation, event scope, numbering, and cost behavior in long sentences. Use “you” and separate each guarantee into its own sentence.

Proposed wording
- Acquire a lease that keeps [`Event::EncDecryptFailed`](/concepts/events#encdecryptfailed) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::EncDecryptFailed` added back in, or it won't see the event even while a lease is held.
+ Acquire a lease to keep [`Event::EncDecryptFailed`](/concepts/events#encdecryptfailed) enabled for your consumer. The lease does not enable delivery by itself. If you narrow your handler's `interest()` away from `EventInterest::ALL`, add `EventKind::EncDecryptFailed` back in.

As per coding guidelines, use active voice and second-person wording, and keep one idea per sentence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/client.mdx` around lines 2074 - 2081, Rewrite the EncDecryptFailedLease
documentation using direct second-person guidance and active voice. Split the
activation requirement, per-enc scope and enc_index numbering, separate-counter
behavior, RAII lifetime, weak-reference behavior, and no-lease cost guarantee
into short sentences with one contract detail per sentence; preserve all
existing semantics.

Source: Coding guidelines

</Note>

**Example:**
```rust
use wacore::types::events::{ChannelEventHandler, Event};

let (handler, event_rx) = ChannelEventHandler::new();
client.register_handler(handler);

let _lease = client.acquire_enc_decrypt_failed_forwarding();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

while let Ok(event) = event_rx.recv().await {
if let Event::EncDecryptFailed(failed) = &*event {
// Not a display signal and not a loss report — see the caveats on
// `Event::EncDecryptFailed` — but useful for attributing a failure
// inside a fan-out or measuring session health per peer.
println!(
"enc #{} ({:?}) for {} failed: {:?}",
failed.enc_index, failed.enc_type, failed.info.id, failed.reason,
);
}
}
// Drop `_lease` to stop forwarding.
```

### acquire_sent_frame_forwarding

```rust
Expand Down
73 changes: 73 additions & 0 deletions api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ As of PR #1195, `ErrorChainExt` also answers `http_status()` — the HTTP status

As of PR #1257, `IqError::ServerError` also carries `response: RejectionStanza` — the `type="error"` stanza itself, handed over whole the same way a `type="result"` response already was, alongside the four fields this crate parses off it (`code`, `text`, `error_type`, `backoff`). See [`RejectionStanza`](#rejectionstanza) below.

As of PR #1261, `wacore::bot_message::decrypt_bot_message` (and its private decryption helper) return `Result<T, BotMessageError>` instead of `anyhow::Result<T>`. Unlike the entries above, this is **a breaking change to `wacore`'s public API**, not an additive one — a caller matching on the previous `anyhow::Error` needs to switch to the typed enum. See [`BotMessageError`](#botmessageerror) below.

## Error hierarchy

```
Expand Down Expand Up @@ -57,6 +59,13 @@ IqError (IQ request failures — embedded by most domain errors)
├── DuplicateRequestId(String)
├── EncodeError
└── ParseError

BotMessageError (wacore::bot_message::decrypt_bot_message — wacore crate, not whatsapp_rust)
├── InvalidSecretLength
├── InvalidIvLength
├── PayloadTooShort
├── KeyDerivation
└── AuthenticationFailed
```

Domain errors that embed `IqError` or `ClientError` via `#[from]` propagate those failures automatically via `?`. Some errors (e.g. `AppStateError`, `SignalError`) use internal `anyhow::Error` wrapping instead and do not have `Iq` or `Client` variants.
Expand Down Expand Up @@ -218,6 +227,7 @@ Rendering changed alongside the `source()` fix: a wrapping variant's `Display` s
| `ConnectError` | `Client::connect`, `Client::wait_for_socket`, `Client::wait_for_connected` | `whatsapp_rust` |
| `SignalMaintenanceError` | `Client::rotate_signed_pre_key`, `Client::flush_pending_signal_state` | `whatsapp_rust` |
| `MessageEditError` | `EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`, `SecretEncrypted::original_sender_for_dispatch` | `whatsapp_rust` |
| `BotMessageError` | `wacore::bot_message::decrypt_bot_message` | `wacore` |

## Type definitions

Expand Down Expand Up @@ -569,6 +579,44 @@ pub enum MessageEditError {

Both variants mean the peer sent a target message key that cannot be attributed — retrying the same envelope yields the same result. `MessageEditError` is re-exported from the crate root as `whatsapp_rust::MessageEditError`.

### BotMessageError

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Format the type name as code.

Use ### \BotMessageError`` for this code reference in the heading. As per coding guidelines, use code formatting for code references.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/errors.mdx` at line 582, Format the BotMessageError type name as inline
code in the heading by wrapping it with backticks, while preserving the existing
heading level and text.

Source: Coding guidelines


Returned by `wacore::bot_message::decrypt_bot_message` and its private decryption helper. Added in PR #1261, replacing bare `anyhow::Result`.

<Note>
Unlike every other error type on this page, `BotMessageError` lives in the `wacore` crate, not `whatsapp_rust` — and its introduction is **a breaking change to `wacore`'s public API**: `decrypt_bot_message`'s return type changed from `anyhow::Result<T>` to `Result<T, BotMessageError>`, so a caller matching on the previous `anyhow::Error` needs to switch to this typed enum.
</Note>

```rust
#[non_exhaustive]
pub enum BotMessageError {
InvalidSecretLength,
InvalidIvLength,
PayloadTooShort,
KeyDerivation,
AuthenticationFailed,
}
```

**Variants:**
- `InvalidSecretLength` — the bot message secret is not the expected size.
- `InvalidIvLength` — the IV carried by the payload is not the expected size.
- `PayloadTooShort` — the payload is too short to contain what it claims to.
- `KeyDerivation` — deriving the decryption key from the secret failed.
- `AuthenticationFailed` — the ciphertext did not verify (AES-GCM tag mismatch).

`BotMessageError::stage(&self) -> BotMessageFailure` classifies which stage of decryption a failure belongs to, without matching every variant by name:

```rust
pub enum BotMessageFailure {
Envelope,
Secret,
Authentication,
}
```

Use `stage()` when you only care whether the envelope, the secret, or the authentication step failed — for metrics or a coarse retry policy — rather than the specific `BotMessageError` variant.

### ClientError (base type)

```rust
Expand Down Expand Up @@ -846,3 +894,28 @@ let err: IqError = wacore_err.into();
// After — pass the response the classification failure was read from
let err = IqError::from_response(wacore_err, &response);
```

### From PR #1261: `decrypt_bot_message` returns a typed error

If you were matching on `anyhow::Error` from `wacore::bot_message::decrypt_bot_message`, switch to [`BotMessageError`](#botmessageerror):

```rust
// Before
match decrypt_bot_message(message_secret, enc_iv, enc_payload, &ctx) {
Ok(plaintext) => { /* ... */ }
Err(e) => eprintln!("bot message decrypt failed: {e}"),
}

// After — match specific variants, or classify by stage
use wacore::bot_message::{BotMessageError, BotMessageFailure};

match decrypt_bot_message(message_secret, enc_iv, enc_payload, &ctx) {
Ok(plaintext) => { /* ... */ }
Err(e @ BotMessageError::AuthenticationFailed) => eprintln!("tampered or wrong secret: {e}"),
Err(e) => match e.stage() {
BotMessageFailure::Envelope => eprintln!("malformed envelope: {e}"),
BotMessageFailure::Secret => eprintln!("bad secret: {e}"),
BotMessageFailure::Authentication => eprintln!("authentication failed: {e}"),
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```
103 changes: 103 additions & 0 deletions concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ pub enum Event {

// Sent frame (opt-in)
SentFrame(SentFrame),

// Enc decrypt failure (opt-in)
EncDecryptFailed(EncDecryptFailed),
}
```

Expand Down Expand Up @@ -2895,6 +2898,106 @@ Event::SentFrame(frame) => {

See [`acquire_sent_frame_forwarding`](/api/client#acquire_sent_frame_forwarding) for the lease API, and [WebSocket & Noise Protocol Handling](/advanced/websocket-handling#noisesocket) for how `SendObservers` wires it into the noise sender.

## Enc decrypt failure events

### `EncDecryptFailed`

**Emitted:** One `<enc>` that did not produce a usable message, and why — the failing half of what [`DecryptedPayload`](#decryptedpayload) reports for the succeeding half, at the same granularity (per `<enc>`, not per message) and under the same numbering (`enc_index` comes from the same enumeration as `DecryptedPayload::enc_index`, so the two events index one stanza and not two). To receive this event, hold a lease from `client.acquire_enc_decrypt_failed_forwarding()` and include `EventKind::EncDecryptFailed` in your handler's `interest()`. While no lease is held, nothing is emitted and nothing is built.

```rust
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct EncDecryptFailed {
pub info: Arc<MessageInfo>,
pub enc_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub enc_type: Option<Cow<'static, str>>,
pub reason: EncDecryptFailureReason,
}

Event::EncDecryptFailed(EncDecryptFailed)
```

**Fields:**
- `info` — Which message this `<enc>` belongs to.
- `enc_index` — Which `<enc>` of the stanza this was, counting from zero in the order the client enumerates them — the stanza's direct `<enc>` children first, then the ones under `<participants><to>` addressed to this device. Not a child index.
- `enc_type` — The `type` attribute the `<enc>` carried: `msg`, `pkmsg`, `skmsg`, … `None` only when the node carried no `type` attribute at all — the one thing `MalformedNode` can mean here that a present type does not.
- `reason` — Where the client stopped. See [`EncDecryptFailureReason`](#encdecryptfailurereason) below.

This is a library extension with no WhatsApp Web equivalent — WhatsApp's own client does not surface this. Reasons to want it: attributing a failure inside a fan-out (a stanza can carry one `<enc>` per device), driving a retry or resync policy off the specific reason, and measuring session health per peer rather than per message.

#### EncDecryptFailureReason

Why one `<enc>` produced no plaintext. This is the client's own classification of where *it* stopped, not something the server sends and not a statement about the sender's copy. It is `#[non_exhaustive]` — a `match` needs a `_` arm, since new branches append new variants as the receive path is refined.

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum EncDecryptFailureReason {
MalformedNode,
UnsupportedEncType,
MalformedCiphertext,
NoSession,
NoSenderKey,
UnknownPreKey,
UntrustedIdentity,
BadMac,
InvalidMessage,
NoMessageSecret,
LocalCryptoFailure,
SignalError,
StorageFailure,
PlaintextUnusable,
NotAttempted,
}
```

| Variant | Meaning |
|---|---|
| `MalformedNode` | No `type` attribute, or no content to decrypt — nothing about the node named a decryption to attempt. |
| `UnsupportedEncType` | Recognized as an `<enc>`, but the `type` is one this build does not implement. |
| `MalformedCiphertext` | A `type` this build handles, whose body is not a well-formed envelope for it — never reached a cipher. Distinct from `InvalidMessage`, which is the cryptographic layer rejecting an envelope it did parse. |
| `NoSession` | No Signal session for the sender's address. Usually recoverable — the client asks the sender to re-establish one. |
| `NoSenderKey` | No sender-key state for this `(group, sender)` chain — typically an `skmsg` whose distribution message was never received or was lost. |
| `UnknownPreKey` | The sender encrypted to a one-time or signed pre-key of ours that this device no longer holds. |
| `UntrustedIdentity` | The sender's identity key is not the one this device trusts for them. Usually reported after a stored-identity retry still didn't produce plaintext; also reported where libsignal raises it directly, as with a PN→LID session migration. |
| `BadMac` | The ciphertext did not verify under the key the client derived for it — covers both the Signal MAC and the AES-GCM tag of a bot (`msmsg`) payload. |
| `InvalidMessage` | The envelope parsed and the cryptographic layer rejected its contents: a version mismatch, a bad signature, or a body the cipher would not accept under sound keys. Not the same as state that was never sound — that's `StorageFailure`. |
| `NoMessageSecret` | A bot (`msmsg`) payload whose `messageSecret` this device does not hold, or whose `<meta>` doesn't say which secret to look up. Expected on a companion for a group bot invocation the primary device sent. |
| `LocalCryptoFailure` | The local cryptographic provider failed a key agreement. Ours — like `StorageFailure`, the peer's ciphertext was never judged. |
| `SignalError` | The cryptographic layer failed for a reason this build does not classify further. |
| `StorageFailure` | Local state was the problem, not the ciphertext — a store that wouldn't answer, a corrupt pre-key/identity/session/sender-key record, or a durability failure. Says nothing about the peer, and nothing about recovery (some branches leave the stanza queued for redelivery, others nack it). |
| `PlaintextUnusable` | The `<enc>` decrypted, but the bytes couldn't be turned into a message (padding it couldn't strip, or an undecodable payload). The one reason that can accompany a `DecryptedPayload` for the same `<enc>` — when the bytes existed but were unusable, both are emitted. |
| `NotAttempted` | Never attempted: an `skmsg` whose stanza's session `<enc>` failed first (the sender key it needed came in that one), a session `<enc>` on a stanza addressed from a group, or a stanza abandoned when the connection tore down before its turn. Says nothing about the ciphertext, which was never read. |

`EncDecryptFailureReason::decryption_was_attempted(self) -> bool` reports whether the client entered its decryption path at all — `false` for `MalformedNode`, `UnsupportedEncType`, and `NotAttempted`; `true` for every other variant, spanning everything from an envelope that wouldn't parse to a MAC that wouldn't verify.

#### What it does not say

<Note>
- **Not a display signal.** Whether to show the user a placeholder is [`UndecryptableMessage`](#undecryptablemessage), which is per *message*, deduplicated by `(chat, id)`, and carries the server's `decrypt-fail` hint. `EncDecryptFailed` is per `<enc>`, is not deduplicated, and answers a different question.
- **Not a loss report.** Most reasons are recoverable — the client may already have asked the sender to resend — and this event says nothing about whether a retry went out or succeeded later.
- **Repeats.** A redelivered stanza that fails again emits it again, once per `<enc>` per delivery. Correlate on `info.id` and `enc_index` if you want at-most-once per `<enc>`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include chat and sender in the deduplication key

When different chats or senders reuse the same stanza ID, this updated recommendation still collapses unrelated failure events even after adding enc_index. Fresh repository evidence in advanced/inbound-durability.mdx:125 explicitly states that stanza IDs are unique only within a (chat, sender) pair, so the at-most-once key should include info.source.chat, info.source.sender, info.id, and enc_index.

Useful? React with 👍 / 👎.

- **A duplicate is not a failure.** An `<enc>` the server redelivered that this device already processed emits neither this event nor [`DecryptedPayload`](#decryptedpayload) — its plaintext was reported the first time round. A stanza whose only duplicate `<enc>` sits beside one that genuinely fails still reports that other `<enc>` as `NotAttempted`, on every delivery, since no delivery ever produced its plaintext.
- **Order is `enc_index`, not arrival.** The client decrypts a stanza's `<enc>` nodes in per-kind passes (session, then group, then bot), so neither these events nor `DecryptedPayload`s arrive in stanza order, and a failure for a later `<enc>` can precede a success for an earlier one.
</Note>

**Example:**
```rust
let _lease = client.acquire_enc_decrypt_failed_forwarding();

// In your event handler:
Event::EncDecryptFailed(failed) => {
println!(
"enc #{} ({:?}) for {} failed: {:?}",
failed.enc_index, failed.enc_type, failed.info.id, failed.reason,
);
}
// Drop `_lease` to stop forwarding.
```

See [`acquire_enc_decrypt_failed_forwarding`](/api/client#acquire_enc_decrypt_failed_forwarding) for the lease API.

## Event handler patterns

### Bot builder pattern
Expand Down