Skip to content

Add WhatsApp phone-number pairing (no QR race) + clear expiry UX#78

Merged
MaxGhenis merged 4 commits into
mainfrom
whatsapp-pair-code
Jul 5, 2026
Merged

Add WhatsApp phone-number pairing (no QR race) + clear expiry UX#78
MaxGhenis merged 4 commits into
mainfrom
whatsapp-pair-code

Conversation

@MaxGhenis

Copy link
Copy Markdown
Owner

Closes #77.

Problem

WhatsApp QR pairing has a ~2–3 minute window that closes silently. In three consecutive real attempts the scan landed after the window had expired: the phone hung on "waiting for the other device" and the desktop showed nothing — qr_event just flipped to timeout and the QR card vanished.

Fix

Phone-number pairing removes the race. whatsmeow's PairPhone returns an 8-character code the user types into WhatsApp → Linked devices → Link with phone number instead. No camera, no expiring image, and the code is plain text — so it can be surfaced anywhere, including driven by an agent over the HTTP API for the recurring re-pair (see the runbook's WhatsApp-logout note).

  • whatsapplive.Bridge.PairPhone(phone) connects the unpaired client and requests the code; pairing completes over the same connection via the existing PairSuccess → Connected events — identical success/persistence path to QR.
  • App.PairWhatsAppPhone + POST /api/whatsapp/pair-code {phone}{code, status}.
  • Web UI: "Can't scan? Link with a phone number instead" under the QR; number field + code rendered dash-split like WhatsApp shows it; auto-hidden once paired.
  • Expiry UX (WhatsApp pairing: surface QR expiry clearly + add phone-number pairing code option #77 item 1): a timed-out QR now explains "The pairing window expired… click Connect for a fresh code, or link with a phone number" instead of leaking a raw timeout string as an error.

Scope note

No openmessage pair --whatsapp-code CLI subcommand: it would open a second bridge in the CLI's separate data dir and fight the running app for the linked-device slot (the two-data-dir footgun in docs/agent-runbook.md). The HTTP endpoint against the live app is the correct agent-driveable path.

Testing

  • Go API: success (phone forwarded, code+status returned), bad-request/wrong-method (400/405), no-bridge (404).
  • e2e: code flow renders E2EP-AIR1 dash-split; affordance hidden while paired; expired-window helper text with no error banner.
  • go build / go vet / go test green; full Playwright suite 91/91.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits July 4, 2026 11:46
Closes #77. Pairing WhatsApp by QR has a ~2-3 minute window that closes
silently; three real attempts failed because the scan landed after the
window expired, with the phone stuck on "waiting for the other device"
and nothing on the desktop explaining why.

Phone-number pairing removes the race entirely. whatsmeow's PairPhone
returns an 8-character code the user types into WhatsApp (Linked devices
-> Link with phone number instead) — no camera, no expiring image, and
the code can be surfaced as plain text (so an agent can drive a re-pair
over the HTTP API without the user racing a QR).

- whatsapplive.Bridge.PairPhone(phone): connects the unpaired client and
  requests a linking code; pairing completes over the same connection
  through the existing PairSuccess -> Connected events (same success
  state as QR), so persistence and status handling are unchanged.
- App.PairWhatsAppPhone + POST /api/whatsapp/pair-code {phone} ->
  {code, status}.
- Web UI: "Can't scan? Link with a phone number instead" under the QR,
  with a number field and the code rendered dash-split like WhatsApp
  shows it; hidden once a session is paired.
- Expiry UX (#77 item 1): a timed-out QR now shows "The pairing window
  expired… click Connect for a fresh code, or link with a phone number"
  instead of leaking a raw "timeout" error string.

Deliberately no `openmessage pair --whatsapp-code` CLI subcommand: it
would open a second bridge in the CLI's separate data dir and fight the
running app for the linked-device slot (the two-data-dir footgun in the
runbook). The HTTP endpoint against the live app is the correct
agent-driveable path.

Tests: 3 new Go API tests (success, bad-request/method, no-bridge), 3
new e2e tests (code flow renders dash-split code, affordance hidden when
paired, expired-window helper text with no raw error). go build/vet/test
green, full e2e 91/91.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e connected

Two fixes found by driving the new endpoint against the live app:

1. PairPhone was sending clientDisplayName "OpenMessage (macOS)". WhatsApp
   validates this against a `Browser (OS)` allowlist server-side and
   returns "400 bad-request" for a product name, so every code request
   failed. Use "Chrome (macOS)" (matches PairClientChrome). The Go/e2e
   tests stub PairPhone, so they can't catch a server-side rejection —
   this only surfaces against the real WhatsApp servers.

2. handleConnected marked the bridge connected=true when *events.Connected
   fired on the unpaired pairing transport, flipping the UI to "Connected"
   and hiding the code the user still had to enter. Skip it while
   Store.ID == nil so the pairing affordance stays until PairSuccess.

Verified live: POST /api/whatsapp/pair-code now returns a code with
status {pairing:true, connected:false, paired:false}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WhatsApp pairing/connection failures were invisible: the client was
constructed with waLog.Noop, so a failed pair or a stream teardown left
no trace and the bridge just returned to idle. Wire whatsmeow's internal
logger to the bridge zerolog (component=whatsmeow) so pairing handshake
stages, stream-end frames, and connect errors are diagnosable.

This surfaced the phone-pairing completion path end to end
(companion_hello -> primary_hello -> companion_finish) when debugging a
live re-pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Driving the pair-code endpoint against the live app revealed why every
phone-number code failed on this account: the server accepts the code
(companion_finish -> ok) and then sends passkey_prologue_request — a
WebAuthn challenge for passkey-protected accounts. Our whatsmeow pin
(2026-03-27) logged it as an unhandled notification and the pairing
idled out silently; the phone showed "Couldn't link device".

- Bump whatsmeow 2026-03-27 -> 2026-06-30 (b572e5b "pair: add support
  for passkeys", plus "client: ignore connect success before pairing is
  completed" and "pair-code: fix platform ID type").
- Handle events.PairPasskeyRequest/PairPasskeyError in the bridge: we
  cannot produce a companion-side WebAuthn assertion (the passkey lives
  on the user's phone/keychain), so fail fast with "this WhatsApp
  account is protected by a passkey ... scan the QR code instead"
  instead of idling. QR linking has no passkey step.
- Adapt downloadMediaWithPath to the new upstream signature (mmsType +
  allowNoHash replace fileLength + fileEncSHA256B64); hash verification
  now enforced whenever a plaintext hash is stored.
- Runbook: new "WhatsApp linking" section — passkey/code incompat, code
  and QR expiry windows, the "Couldn't link device. Try again later."
  throttle + zombie-device diagnosis, live log capture recipe, and the
  go.work local-override gotcha that ate the first bump attempt.

go build/vet green; full go test green; deployed live and verified the
passkey error now surfaces instead of a silent idle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis merged commit 29a13ff into main Jul 5, 2026
5 checks passed
@MaxGhenis MaxGhenis deleted the whatsapp-pair-code branch July 5, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WhatsApp pairing: surface QR expiry clearly + add phone-number pairing code option

1 participant