diff --git a/api/bot.mdx b/api/bot.mdx index ea0c07ac..df03b0a9 100644 --- a/api/bot.mdx +++ b/api/bot.mdx @@ -658,7 +658,7 @@ where Fut: Future + Send + 'static ``` -Registers a handler for [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh), fired when the server asks the companion to refresh an in-progress pair code. The `bool` argument is `force_manual`. +Registers a handler for [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh), fired when the in-progress phone-number pairing code should be replaced. The `bool` argument is `force_manual`. Async function that receives `force_manual: bool` and `Arc` @@ -674,9 +674,9 @@ Bot::builder() ..Default::default() }) .on_pair_code_refresh(|force_manual, client| async move { - println!("Server requested a pair-code refresh (force_manual={force_manual})"); - // The previous code is no longer guaranteed valid — request a fresh - // one with the same phone number. + println!("Pair code needs a refresh (force_manual={force_manual})"); + // The previous code is no longer valid, and the flow is already + // clear — request a fresh one with the same phone number. let _ = client.pair_with_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() @@ -686,7 +686,7 @@ Bot::builder() ``` -Only fires while a pair-code flow is outstanding and the notification's ref matches it — a `refresh_code` notification for a stale or unrelated flow is ignored. +This callback fires for **two** triggers, not just a server request: the server asking for a refresh (only while a pair-code flow is outstanding and the notification's ref matches it — a `refresh_code` notification for a stale or unrelated flow is ignored), and a `companion_finish` that went unanswered for a minute after the phone entered the code (`force_manual` is always `false` for this second trigger). See [Pair code refresh events](/concepts/authentication#pair-code-refresh-events) for the full breakdown. --- diff --git a/api/client.mdx b/api/client.mdx index b119ca37..868ae950 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -269,6 +269,10 @@ Initiates pair code authentication as an alternative to QR code pairing. The ret This can run concurrently with QR code pairing — whichever completes first wins. + +**One code at a time.** Fails with `PairCodeError::CodeAlreadyOutstanding` while a previous code is still outstanding, instead of silently replacing it. "Outstanding" means either the previous code's validity window hasn't elapsed yet, *or* its `primary_hello` was already accepted and a `pair-success` for it is still pending — that second case can outlast the validity window by up to a minute, and `remaining` reads as `0` for it since there's no window left to report. A second code does not replace the first for the phone: the server routes `primary_hello` by number and never sees the code itself, so whoever is still reading the older one reaches stage 2 regardless. Call [`cancel_pair_code`](#cancel_pair_code) first when the replacement is intentional. Do not call this on a schedule driven by QR-code rotation — the two flows have unrelated lifetimes. See [One code at a time](/concepts/authentication#one-code-at-a-time). + + Configuration for pair code authentication: - `phone_number` — Phone number in international format (e.g., `"15551234567"`) @@ -291,6 +295,8 @@ This can run concurrently with QR code pairing — whichever completes first win | `PairCode(PhoneNumberTooShort)` | Fewer than 7 digits | | `PairCode(PhoneNumberNotInternational)` | Starts with `0` (not international format) | | `PairCode(InvalidCustomCode)` | Custom code is not 8 valid Crockford Base32 characters | +| `PairCode(CodeAlreadyOutstanding { remaining })` | A previous code is still outstanding (within its validity window, or its `primary_hello` was accepted and `pair-success` is still pending) — call `cancel_pair_code` first | +| `PairCode(Cancelled)` | `cancel_pair_code` was called while `companion_hello` (stage 1) was still in flight | | `PairCode(InvalidPrimaryEphemeralKey)` / `InvalidPrimaryIdentityKey` | Peer key parsing failed (typed `CurveError` source) | | `PairCode(EphemeralKeyAgreement)` / `IdentityKeyAgreement` | Diffie–Hellman failed (typed `CurveError` source) | | `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation` | HKDF expand failed | @@ -315,6 +321,26 @@ println!("Enter this code on your phone: {}", code); --- +### cancel_pair_code + +```rust +pub async fn cancel_pair_code(self: &Arc) +``` + +Abandons the outstanding pair-code flow, if any — the explicit reset [`pair_with_code`](#pair_with_code) requires before it will mint a replacement (WA Web's `initializeAltDeviceLinking()`). A no-op when no flow is outstanding. + + +**Boundary: a `primary_hello` already being processed may still finish.** If no `primary_hello` has been accepted yet, cancellation is immediate and complete — a later `primary_hello` for the cancelled ref is dropped rather than answered. But once the phone's `primary_hello` has been accepted, stage 2 (deriving the key bundle and sending `companion_finish`) runs under the same lock `cancel_pair_code` takes, so the two race: if `cancel_pair_code` wins, stage 2 finds the flow gone and stops before sending anything; if stage 2 already holds the lock, `companion_finish` is sent before `cancel_pair_code` gets a turn. The library never revokes credentials the server has already issued, so an authenticated `pair-success` that follows still completes the link even though `cancel_pair_code` ran. Treat cancellation as reliable only before the phone has entered the code. + + +**Example:** +```rust +client.cancel_pair_code().await; +let code = client.pair_with_code(new_options).await?; +``` + +--- + ### set_passkey_authenticator ```rust diff --git a/concepts/authentication.mdx b/concepts/authentication.mdx index e7fe5285..bde568f6 100644 --- a/concepts/authentication.mdx +++ b/concepts/authentication.mdx @@ -180,6 +180,40 @@ for code in codes_clone { The rotation uses `futures::future::select` with an `async_channel` stop signal rather than Tokio-specific primitives. This keeps the QR rotation compatible with any async runtime, since the `Client` uses the pluggable `Runtime` trait for sleep and spawn operations. +### QR ref exhaustion + +The server hands out six `pair-device` refs per connection (60s for the first, 20s for each of the other five — 160s total). When the rotation runs out of refs, it dispatches [`Event::PairingQrCodesExhausted`](/concepts/events#pairingqrcodesexhausted) rather than disconnecting unconditionally: + +```rust +// src/pair.rs +let pair_code_outstanding = client_clone + .pair_code_state + .lock() + .await + .is_outstanding(wacore::time::now_secs()); + +client_clone.core.event_bus.dispatch(Event::PairingQrCodesExhausted( + PairingQrCodesExhausted::builder() + .disconnected(!pair_code_outstanding) + .build(), +)); +if !pair_code_outstanding { + client_clone.disconnect().await; +} +``` + +A [pair code](#pair-code-phone-number-linking) flow has an unrelated lifetime — a code sits on a phone screen for up to its ~180s validity window (and longer still while a `companion_finish` is pending), which outlasts the 160s the six QR refs buy. Disconnecting unconditionally would revoke a code the client had just told the user was still good, and any `primary_hello` for it would then arrive at a session the server had already dropped. So the client now disconnects **only when no pair-code flow is outstanding** — a QR-only consumer keeps the reconnect-for-fresh-refs behavior it relies on, while a phone-number flow in progress keeps its socket up. + + +`disconnected: true` means the client is about to tear down its own socket, not that it already has: as the snippet above shows, the event dispatches *before* `disconnect().await` is called. A handler registered as a plain `EventHandler` runs inline during `dispatch`, ahead of the disconnect; a `Bot`/`on_event` closure runs off a channel on its own task and can race it either way. + +Note that no [`Event::Disconnected`](/concepts/events#disconnected) follows this particular teardown: `Client::disconnect()` sets the `expected_disconnect` flag, and `Disconnected` is scoped to disconnects the client did *not* intend — so waiting on it here would hang forever. `disconnect()` also disables auto-reconnect. To resume, call [`connect()`](/api/client#connect) yourself once you're ready to retry (it returns `ConnectError::AlreadyConnected` if the previous teardown is still finishing — back off briefly and retry rather than treating that as fatal). + + + +Breaking change: this is a new event. If you match on `Event` exhaustively with a wildcard already in place, no change is needed. Code that used to rely on the client always disconnecting when QR refs ran out — e.g. treating any disconnect during pairing as "start over" — should instead branch on `PairingQrCodesExhausted.disconnected` and reload only when it's `true`. + + ### Native-camera deep link (open WhatsApp directly) By default the `Event::PairingQrCode` `code` is the **raw** comma-separated string above. It is meant to be scanned from *inside* WhatsApp (**Linked Devices → Link a Device**) — it is **not** a URL and tapping it does nothing. @@ -285,6 +319,46 @@ let code = client.pair_with_code(options).await?; assert_eq!(code, "MYCODE12"); ``` +### One code at a time + +A second code does not replace the first *for the phone*: the server routes `primary_hello` by phone number, never seeing the code itself, so whoever is still reading the older code reaches stage 2 and is handed a key bundle their code cannot open — the phone reports a failed link and the companion sees nothing. WA Web forbids the overlap outright (`invariant(stage === Initialized)` in `Alt/DeviceLinkingApi.js`). + +`pair_with_code` now enforces the same rule: it fails with [`PairCodeError::CodeAlreadyOutstanding { remaining }`](#pair-code-errors) while the previous code is still outstanding, instead of silently overwriting it. "Outstanding" is either clock: the code's own validity window, or — once `primary_hello` has been accepted — the pending `pair-success` that follows it, which can run up to a minute past the window (`remaining` reads as `0` in that case, since there's no window left to report). Call `cancel_pair_code` first when the replacement is intentional: + +```rust +use wacore::pair_code::PairCodeError; +use whatsapp_rust::pair_code::PairError; + +match client.pair_with_code(options).await { + Ok(code) => println!("Enter this code on your phone: {code}"), + Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => { + // `remaining` is the code's validity window, not a countdown on the + // overall block: it reads `0` while a pair-success is pending on an + // already-entered code, not "no time left before you may retry". + eprintln!("A code is already outstanding ({remaining:?} left in its validity window) — cancel it first"); + client.cancel_pair_code().await; + } + Err(e) => eprintln!("Pair code request failed: {e}"), +} +``` + + +**Do not drive `pair_with_code` from QR-code rotation.** The two flows have unrelated lifetimes — a pair code is read off a screen and typed into a phone minutes later, well past the point a QR ref would rotate. Re-requesting a code on every QR rotation trips `CodeAlreadyOutstanding` and does not match WA Web, which only regenerates on the server's `refresh_code`, on `force_manual_refresh`, or on its own expiry timers — never on a QR ref rotating. See [Pair code refresh events](#pair-code-refresh-events) for the cases that *do* warrant a new request. + + +`Client::cancel_pair_code()` abandons the outstanding flow, if any — WA Web's `initializeAltDeviceLinking()`. + +```rust +// src/pair_code.rs +pub async fn cancel_pair_code(self: &Arc); +``` + + +**Cancellation is reliable only before the phone enters the code.** Before a `primary_hello` has been accepted, cancelling is immediate and complete: a later `primary_hello` for the cancelled ref is dropped rather than answered with a bundle its holder cannot open. Once `primary_hello` has been accepted, deriving the key bundle and sending `companion_finish` runs under the same lock `cancel_pair_code` takes, so the two race — if stage 2 already holds the lock, `companion_finish` goes out before `cancel_pair_code` gets a turn, and the phone's subsequent `pair-success` still completes the link. The library never revokes credentials the server has already issued. + + +An expired code never blocks a new request — `CodeAlreadyOutstanding` is only returned while the previous code (or a pending `pair-success` for it) is still live. + ### Pair code options ```rust @@ -426,7 +500,12 @@ pub struct PairingCodeRefresh { } ``` -Fired when a `link_code_companion_reg` notification arrives with `stage="refresh_code"` (WA Web `refreshAltLinkingCode` / `forceManualRefresh`) and its `link_code_pairing_ref` matches the outstanding request. The typical reaction is to call [`Client::pair_with_code`](/api/client#pair_with_code) again with the same phone number — the previous code is no longer guaranteed valid. Register a handler with [`Bot::on_pair_code_refresh`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`. +`PairingCodeRefresh` now covers **two** triggers, matching WA Web's `Alt/DeviceLinkingApi.js` and `Link/DevicePhoneNumberCodeScreen.react.js`: + +1. **Server-requested.** A `link_code_companion_reg` notification arrives with `stage="refresh_code"` (WA Web `refreshAltLinkingCode` / `forceManualRefresh`) and its `link_code_pairing_ref` matches the outstanding request. `force_manual` reflects the notification's `force_manual_refresh` attribute. +2. **Unanswered `companion_finish`.** The code was entered on the phone (`primary_hello` accepted) but no `pair-success` arrived within `PairCodeUtils::primary_hello_pair_success_timeout()` (WA Web's one-minute `primary_hello_expire` timer). A primary that fails to open the key bundle just goes quiet — silence is the only signal there is — so the client times the wait out itself and dispatches `PairingCodeRefresh` with `force_manual: false`. + +In both cases the outstanding flow is cleared **before** the event fires, so a handler can call [`Client::pair_with_code`](/api/client#pair_with_code) immediately without hitting [`PairCodeError::CodeAlreadyOutstanding`](#one-code-at-a-time). The previous code is no longer valid either way. Register a handler with [`Bot::on_pair_code_refresh`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`. ### Two-Stage Flow @@ -938,12 +1017,7 @@ Breaking change: `PairSuccess`, `PairError`, and `LoggedOut` (below) are now `#[ ### QR code errors -```rust -// Handled internally, retries with new QR codes -// If all QR codes expire, disconnects: -info!("All QR codes for this session have expired."); -client.disconnect().await; -``` +QR codes are handled internally and retried automatically. If all refs expire, the client dispatches [`Event::PairingQrCodesExhausted`](/concepts/events#pairingqrcodesexhausted) and disconnects **only when no pair-code flow is outstanding** — see [QR ref exhaustion](#qr-ref-exhaustion). ### Pair code errors @@ -967,6 +1041,14 @@ match client.pair_with_code(options).await { Err(PairError::PairCode(PairCodeError::InvalidCustomCode)) => { eprintln!("Custom code must be 8 valid Crockford Base32 characters"); } + Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => { + // `remaining` reads `0` while a pair-success is pending on an + // already-entered code, not "no time left before you may retry". + eprintln!("A code is already outstanding ({remaining:?} left in its validity window)"); + } + Err(PairError::PairCode(PairCodeError::Cancelled)) => { + eprintln!("cancel_pair_code() was called while companion_hello was in flight"); + } Err(PairError::PairCode(PairCodeError::MissingPairingRef)) => { eprintln!("Server did not return a pairing reference"); } @@ -1008,6 +1090,10 @@ match client.pair_with_code(options).await { } ``` + + `CodeAlreadyOutstanding` and `Cancelled` are new — see [One code at a time](#one-code-at-a-time). `CodeAlreadyOutstanding` means `pair_with_code` refused to supersede a code that is still outstanding — either within its validity window, or awaiting a `pair-success` after an accepted `primary_hello`; `Cancelled` means `cancel_pair_code()` withdrew the request while stage 1 (`companion_hello`) was in flight. + + A `PairError::RequestFailed` carrying `bad-request` (400) is not necessarily a permanent/invalid-input failure — the server reuses the same error for **rate-limiting** (it throttles pair-code requests per phone number), and the two are indistinguishable in the response. Back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes `companion_platform_display`'s OS (see [`companion_platform_display`](#companion-platform-display)), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via `PairCodeOptions::display_os`. Any server `backoff` hint is preserved on the wrapped `IqError::ServerError` (see [Error Types](/api/errors#iqerror-base-type)). diff --git a/concepts/events.mdx b/concepts/events.mdx index 116d325b..48d6f8e1 100644 --- a/concepts/events.mdx +++ b/concepts/events.mdx @@ -137,6 +137,7 @@ pub enum Event { PairingQrCode(PairingQrCode), PairingCode(PairingCode), PairingCodeRefresh(PairingCodeRefresh), + PairingQrCodesExhausted(PairingQrCodesExhausted), PairSuccess(PairSuccess), PairError(PairError), QrScannedWithoutMultidevice(QrScannedWithoutMultidevice), @@ -537,17 +538,23 @@ Breaking change: `PairingCode` moved from inline fields on `Event::PairingCode { ### PairingCodeRefresh -**Emitted:** When the server asks the companion to refresh an in-progress pair code (WA Web `refreshAltLinkingCode` / `forceManualRefresh`). Only fired while a pair-code flow is outstanding and the server's ref matches it — a `refresh_code` notification for a stale or unrelated flow is silently ignored. +**Emitted:** When the in-progress phone-number pairing code should be replaced. Covers two triggers (WA Web `Alt/DeviceLinkingApi.js` + `Link/DevicePhoneNumberCodeScreen.react.js`): the server asking for it (`refreshAltLinkingCode` / `forceManualRefresh`, only while a pair-code flow is outstanding and the server's ref matches it — a `refresh_code` notification for a stale or unrelated flow is silently ignored), and a `companion_finish` that went unanswered for a minute (`PairCodeUtils::primary_hello_pair_success_timeout()`) — a primary that could not open the key bundle just goes quiet, so silence is the only signal there is. ```rust -/// The server asked the companion to refresh an in-progress phone-number -/// pairing code. The consumer should request a fresh code via -/// `pair_with_code`; the previous code is no longer guaranteed valid. +/// The in-progress phone-number pairing code should be replaced. +/// +/// Emitted for the two cases WA Web regenerates on: the server asking for it +/// (`refreshAltLinkingCode` / `forceManualRefresh`, ref-gated against the +/// outstanding flow), and an unanswered `companion_finish`. +/// +/// The outstanding flow is cleared before this fires, so the consumer can call +/// `pair_with_code` straight away. The previous code is no longer valid. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeRefresh { /// `true` when the server set `force_manual_refresh` — the code must be - /// re-requested explicitly rather than auto-rotated. + /// re-requested explicitly rather than auto-rotated. Always `false` for + /// the unanswered-`companion_finish` timeout trigger. pub force_manual: bool, } ``` @@ -555,9 +562,9 @@ pub struct PairingCodeRefresh { **Example:** ```rust Event::PairingCodeRefresh(PairingCodeRefresh { force_manual, .. }) => { - // The previous code is no longer guaranteed valid — request a new one, - // e.g. by calling `client.pair_with_code(options)` again. - println!("Server requested a pair-code refresh (force_manual={force_manual})"); + // The previous code is no longer valid, and the flow is already clear — + // request a new one, e.g. by calling `client.pair_with_code(options)` again. + println!("Pair code needs a refresh (force_manual={force_manual})"); } ``` @@ -565,6 +572,53 @@ Event::PairingCodeRefresh(PairingCodeRefresh { force_manual, .. }) => { Breaking change: `PairingCodeRefresh` moved from an inline `Event::PairingCodeRefresh { force_manual }` field to a dedicated sealed struct — `Event::PairingCodeRefresh(PairingCodeRefresh)`. A `matches!` check on the field becomes `matches!(event, Event::PairingCodeRefresh(r) if r.force_manual)`. + +The unanswered-`companion_finish` timeout trigger is new. If your handler assumed `PairingCodeRefresh` only ever came from the server, no code changes are required — the reaction (call `pair_with_code` again) is the same either way, and the outstanding flow is already cleared by the time the event fires in both cases. + + +### PairingQrCodesExhausted + +**Emitted:** When the server's `` refs are used up — there is no QR left to render until the connection is re-established. WA Web's rotation timer (`Handle/PairDevice.js`) reports `UNPAIRED_IDLE` here and stops; it does not close the socket unconditionally, because a phone-number (pair-code) flow may still be riding the same connection. + +```rust +/// The server's `` refs are used up: there is no QR left to +/// render until the connection is re-established. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct PairingQrCodesExhausted { + /// `true` when the client closed the connection itself, which it only does + /// with no pair-code flow outstanding. `false` means the socket was left + /// up and reconnecting is the consumer's call. + pub disconnected: bool, +} +``` + +**Example:** +```rust +Event::PairingQrCodesExhausted(PairingQrCodesExhausted { disconnected, .. }) => { + if disconnected { + // No pair-code flow was outstanding, so the client is tearing its own + // socket down and won't auto-reconnect. Call connect() yourself + // (with a short backoff) to get a fresh batch of refs. + println!("QR refs exhausted, disconnecting — call connect() to retry"); + } else { + // A pair-code flow is still outstanding, so the socket is being left + // up to keep carrying it. There is simply no QR to show right now. + println!("QR refs exhausted, but a pair code is still in progress"); + } +} +``` + +See [QR ref exhaustion](/concepts/authentication#qr-ref-exhaustion) for why this no longer disconnects unconditionally. + + +`disconnected: true` reports intent, not a completed action: the client dispatches this event *before* awaiting `disconnect()`, so the socket may still be open at the moment a handler observes it. A synchronous `EventHandler` runs inline ahead of the disconnect; a `Bot`/`on_event` closure runs off a channel on its own task and can race it either way. **No [`Event::Disconnected`](#disconnected) follows**, though: `disconnect()` sets `expected_disconnect`, and `Disconnected` is scoped to disconnects the client did not itself intend, so waiting on it here hangs forever. `disconnect()` also disables auto-reconnect — call [`connect()`](/api/client#connect) yourself when ready to retry; it returns `ConnectError::AlreadyConnected` if the previous teardown is still finishing, so back off briefly on that rather than treating it as fatal. + + + +This is a new event and a new `EventKind` variant, added at the **end** of `EventKind` (after `ServerAck`) rather than next to `Event::PairingQrCodesExhausted` — new kinds always go at the end, since the discriminant is what a consumer persists or transmits and inserting in the middle would renumber every kind after it. + + ### PairSuccess **Emitted:** When pairing completes successfully