diff --git a/cmd/e2e-server/main.go b/cmd/e2e-server/main.go index a8a7a7d..f50c7f5 100644 --- a/cmd/e2e-server/main.go +++ b/cmd/e2e-server/main.go @@ -108,6 +108,9 @@ func main() { WhatsAppStatus: func() any { return map[string]any{"connected": true, "paired": true} }, + PairWhatsAppPhone: func(phone string) (string, error) { + return "E2EPAIR1", nil + }, SignalStatus: func() any { return map[string]any{"connected": true, "paired": true, "account": "+15551234567"} }, diff --git a/cmd/serve.go b/cmd/serve.go index e028a65..a621b8a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -436,6 +436,7 @@ func RunServe(logger zerolog.Logger, args ...string) error { Unpair: a.Unpair, WhatsAppStatus: func() any { return a.WhatsAppStatus() }, ConnectWhatsApp: a.StartWhatsAppConnect, + PairWhatsAppPhone: a.PairWhatsAppPhone, UnpairWhatsApp: a.UnpairWhatsApp, SignalStatus: func() any { return a.SignalStatus() }, ConnectSignal: a.StartSignalConnect, diff --git a/docs/agent-runbook.md b/docs/agent-runbook.md index f24ba5d..98669a2 100644 --- a/docs/agent-runbook.md +++ b/docs/agent-runbook.md @@ -141,6 +141,41 @@ Connecting/disconnecting the Google web session many times in a short window valid session. The fix is to **stop and let it cool down** (minutes up to ~1h), not to hammer reconnect. Sends may land in brief connected windows meanwhile. +## WhatsApp linking (QR and phone-number code) + +Hard-won facts from the 2026-07-03/04 re-pair ordeal: + +- **Passkey-protected accounts can't use phone-number code linking.** If the + account has a WhatsApp passkey, the server accepts the typed code + (`companion_finish` returns ok) and then sends `passkey_prologue_request` — + a WebAuthn challenge only the user's real authenticator can answer. The + phone shows "Couldn't link device"; the desktop used to idle silently + (whatsmeow logged "Unhandled notification"). The bridge now surfaces a + clear "account is protected by a passkey — scan the QR code instead" error + via `events.PairPasskeyRequest`. QR linking does **not** involve the + passkey step (the camera scan is the verification). +- **Code + QR windows are short.** Pairing codes expire in ~2 minutes; + QR refs rotate ~20–60s within a ~3-minute session that ends silently + (`qr_event: "timeout"`). Generate the code / show the QR only when the + user's phone is already on the entry/scanner screen. +- **"Couldn't link device. Try again later." on every QR scan = WhatsApp + refusing, not our bug.** Two causes: (a) zombie companion entries from + failed attempts eating the 4-device limit — have the user clear stale + entries in WhatsApp → Linked Devices; (b) a temporary linking throttle + after repeated failed attempts — stop retrying for a few hours (hammering + extends it). If a single clean attempt after cleanup + cooldown still + fails, remove the WhatsApp passkey (Settings → Account → Passkeys), link, + re-add it. +- **Debugging:** whatsmeow logs used to be discarded (`waLog.Noop`); they now + flow through the bridge logger (`component=whatsmeow`). Debug-level os_log + lines are NOT persisted — capture live with + `log stream --predicate 'subsystem == "com.openmessage.app"' --level debug` + **before** the attempt; `log show` after the fact only has warn/error. +- **`go.work` gotcha:** the repo root had an untracked `go.work` whose + `use ../tmp/whatsmeow` silently overrode go.mod's whatsmeow pin for every + workspace-mode build (including `macos/build.sh`). If a dependency bump + mysteriously doesn't take, check `go.work`. + ## signal-cli Require **signal-cli ≥ 0.14.5**. 0.14.1 throws diff --git a/e2e/ui.spec.js b/e2e/ui.spec.js index 009adc6..9be0857 100644 --- a/e2e/ui.spec.js +++ b/e2e/ui.spec.js @@ -100,6 +100,54 @@ test.beforeEach(async ({ page, request }) => { await page.goto('/'); }); +test('links WhatsApp with a phone number pairing code when unpaired', async ({ page }) => { + // Force the no-local-session state via the status route so the overlay's + // own refresh + polling see it consistently (the e2e server's global stub + // reports connected+paired, which hides the pair-code affordance). + await page.route('**/api/whatsapp/status', async route => { + await route.fulfill({ + json: { connected: false, paired: false, pairing: false, qr_available: false }, + }); + }); + await page.route('**/api/whatsapp/pair-code', async route => { + await route.fulfill({ + json: { code: 'E2EPAIR1', status: { connected: false, paired: false, pairing: true, qr_available: false } }, + }); + }); + await openPlatforms(page); + await expect(page.locator('#wa-pair-code')).toBeVisible(); + + await page.locator('#wa-pair-code-toggle').click(); + await expect(page.locator('#wa-pair-code-form')).toBeVisible(); + await page.locator('#wa-pair-code-input').fill('+16505551234'); + await page.locator('#wa-pair-code-btn').click(); + + // The UI renders the 8-char code dash-split like WhatsApp shows it. + await expect(page.locator('#wa-pair-code-result')).toBeVisible(); + await expect(page.locator('#wa-pair-code-value')).toHaveText('E2EP-AIR1'); +}); + +test('hides the WhatsApp pair-code affordance while a session is paired', async ({ page }) => { + // Default e2e-server status is connected+paired. + await openPlatforms(page); + await expect(page.locator('#wa-pair-code')).toBeHidden(); +}); + +test('explains an expired WhatsApp pairing window instead of a raw timeout error', async ({ page }) => { + await page.route('**/api/whatsapp/status', async route => { + await route.fulfill({ + json: { + connected: false, paired: false, pairing: false, + qr_available: false, qr_event: 'timeout', last_error: 'timeout', + }, + }); + }); + await openPlatforms(page); + await expect(page.locator('#wa-helper')).toContainText('pairing window expired'); + // The raw "timeout" string must not surface as an error banner. + await expect(page.locator('#wa-error')).toBeHidden(); +}); + test('native bridge global window.openWhatsAppOverlay opens the platforms overlay', async ({ page }) => { // The macOS wrapper's "Pair in inbox" / menu-bar alert buttons call this via // evaluateJavaScript. The app script is an IIFE, so the function must be diff --git a/go.mod b/go.mod index 4f3e511..0d8b6d3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/rs/zerolog v1.35.1 go.mau.fi/mautrix-gmessages v0.2601.0 go.mau.fi/util v0.9.10 - go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 + go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b golang.org/x/crypto v0.53.0 golang.org/x/net v0.56.0 golang.org/x/term v0.44.0 @@ -37,8 +37,9 @@ require ( github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/libsignal v0.2.2 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6f57c90..b40d873 100644 --- a/go.sum +++ b/go.sum @@ -77,10 +77,14 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 h1:E4A6eca9vMJQctC9DIfzUIg27TrJ8IrDHgkJwJ8WPUQ= go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= diff --git a/internal/app/whatsapp.go b/internal/app/whatsapp.go index b742b46..cf3ece6 100644 --- a/internal/app/whatsapp.go +++ b/internal/app/whatsapp.go @@ -62,6 +62,14 @@ func (a *App) StartWhatsAppConnect() error { return nil } +func (a *App) PairWhatsAppPhone(phone string) (string, error) { + bridge, err := a.ensureWhatsApp() + if err != nil { + return "", fmt.Errorf("init WhatsApp bridge: %w", err) + } + return bridge.PairPhone(phone) +} + func (a *App) UnpairWhatsApp() error { bridge, err := a.ensureWhatsApp() if err != nil { diff --git a/internal/web/api.go b/internal/web/api.go index 32c7b2b..ca16ea0 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -90,6 +90,7 @@ type APIOptions struct { Unpair UnpairFunc WhatsAppStatus func() any ConnectWhatsApp func() error + PairWhatsAppPhone func(phone string) (string, error) UnpairWhatsApp func() error SignalStatus func() any ConnectSignal func() error @@ -2597,6 +2598,36 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L writeJSON(w, opts.WhatsAppStatus()) }) + mux.HandleFunc("/api/whatsapp/pair-code", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httpError(w, "method not allowed", 405) + return + } + if opts.PairWhatsAppPhone == nil || opts.WhatsAppStatus == nil { + httpError(w, "whatsapp live bridge not available", 404) + return + } + var body struct { + Phone string `json:"phone"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + httpError(w, "invalid request body", 400) + return + } + phone := strings.TrimSpace(body.Phone) + if phone == "" { + httpError(w, "phone number is required (international format, e.g. +15551234567)", 400) + return + } + code, err := opts.PairWhatsAppPhone(phone) + if err != nil { + httpError(w, "whatsapp pairing code: "+err.Error(), 502) + return + } + publishStatus(currentConnected()) + writeJSON(w, map[string]any{"code": code, "status": opts.WhatsAppStatus()}) + }) + mux.HandleFunc("/api/whatsapp/qr", func(w http.ResponseWriter, r *http.Request) { if opts.WhatsAppQRCode == nil { httpError(w, "whatsapp qr not available", 404) diff --git a/internal/web/api_test.go b/internal/web/api_test.go index 0126c5f..57007e3 100644 --- a/internal/web/api_test.go +++ b/internal/web/api_test.go @@ -2139,6 +2139,85 @@ func TestWhatsAppConnectRoute(t *testing.T) { } } +func TestWhatsAppPairCodeRoute(t *testing.T) { + var gotPhone string + ts := newTestServerWithOptions(t, APIOptions{ + PairWhatsAppPhone: func(phone string) (string, error) { + gotPhone = phone + return "ABCD1234", nil + }, + WhatsAppStatus: func() any { + return map[string]any{"pairing": true} + }, + }) + + resp, err := http.Post(ts.server.URL+"/api/whatsapp/pair-code", "application/json", strings.NewReader(`{"phone":"+16505551234"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("got status %d, want 200", resp.StatusCode) + } + if gotPhone != "+16505551234" { + t.Fatalf("handler got phone %q, want %q", gotPhone, "+16505551234") + } + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["code"] != "ABCD1234" { + t.Fatalf("code = %#v, want ABCD1234", payload["code"]) + } + status, ok := payload["status"].(map[string]any) + if !ok || status["pairing"] != true { + t.Fatalf("unexpected status payload: %#v", payload["status"]) + } +} + +func TestWhatsAppPairCodeRouteRejectsBadRequests(t *testing.T) { + ts := newTestServerWithOptions(t, APIOptions{ + PairWhatsAppPhone: func(phone string) (string, error) { + t.Fatal("handler must not be called") + return "", nil + }, + WhatsAppStatus: func() any { return map[string]any{} }, + }) + + // Missing phone. + resp, err := http.Post(ts.server.URL+"/api/whatsapp/pair-code", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 400 { + t.Fatalf("missing phone: got status %d, want 400", resp.StatusCode) + } + + // Wrong method. + getResp, err := http.Get(ts.server.URL + "/api/whatsapp/pair-code") + if err != nil { + t.Fatal(err) + } + getResp.Body.Close() + if getResp.StatusCode != 405 { + t.Fatalf("GET: got status %d, want 405", getResp.StatusCode) + } +} + +func TestWhatsAppPairCodeRouteUnavailableWithoutBridge(t *testing.T) { + ts := newTestServerWithOptions(t, APIOptions{}) + resp, err := http.Post(ts.server.URL+"/api/whatsapp/pair-code", "application/json", strings.NewReader(`{"phone":"+16505551234"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("got status %d, want 404", resp.StatusCode) + } +} + func TestSignalConnectRoute(t *testing.T) { called := false ts := newTestServerWithOptions(t, APIOptions{ diff --git a/internal/web/static/index.html b/internal/web/static/index.html index b6453d2..404d14f 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -580,6 +580,72 @@ color: var(--accent-strong); } +.wa-pair-code { + margin-top: 10px; +} + +.wa-linktext-btn { + background: none; + border: none; + padding: 0; + color: var(--accent-strong); + font: inherit; + font-size: 13px; + cursor: pointer; + text-decoration: underline; +} + +.wa-linktext-btn:hover { + color: var(--accent); +} + +.wa-pair-code-form { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.wa-pair-code-input { + flex: 1 1 180px; + min-width: 160px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text-primary); + font: inherit; +} + +.wa-pair-code-input:focus { + outline: none; + border-color: var(--accent); +} + +.wa-pair-code-result { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--accent-strong); + background: var(--accent-dim); +} + +.wa-pair-code-label { + font-size: 12px; + color: var(--text-secondary); +} + +.wa-pair-code-value { + font-size: 26px; + font-weight: 700; + letter-spacing: 4px; + font-variant-numeric: tabular-nums; + color: var(--accent-strong); + user-select: all; +} + .wa-error { display: none; margin-top: 0; @@ -5372,6 +5438,20 @@

Platforms

+
+ + +
@@ -5720,6 +5800,13 @@

Forward message

const $waQRCodeCaption = document.getElementById('wa-qr-caption'); const $waConnectBtn = document.getElementById('wa-connect-btn'); const $waResetBtn = document.getElementById('wa-reset-btn'); + const $waPairCodeSection = document.getElementById('wa-pair-code'); + const $waPairCodeToggle = document.getElementById('wa-pair-code-toggle'); + const $waPairCodeForm = document.getElementById('wa-pair-code-form'); + const $waPairCodeInput = document.getElementById('wa-pair-code-input'); + const $waPairCodeBtn = document.getElementById('wa-pair-code-btn'); + const $waPairCodeResult = document.getElementById('wa-pair-code-result'); + const $waPairCodeValue = document.getElementById('wa-pair-code-value'); const $waError = document.getElementById('wa-error'); const $signalStatusPill = document.getElementById('signal-status-pill'); const $signalHelper = document.getElementById('signal-helper'); @@ -12120,6 +12207,8 @@

Forward message

$waStatusPill.classList.add('error'); } + const waPairingExpired = !whatsAppStatus.connected && !whatsAppStatus.pairing + && (whatsAppStatus.qr_event === 'timeout' || whatsAppStatus.last_error === 'timeout'); if (whatsAppStatus.connected) { const identity = whatsAppStatus.push_name || whatsAppStatus.account_jid || 'your paired account'; $waHelper.textContent = `Live sync is active for ${identity}. Incoming WhatsApp messages, typing updates, and text replies should now flow through OpenMessage immediately.`; @@ -12130,6 +12219,8 @@

Forward message

$waHelper.textContent = 'Scan the QR code below from WhatsApp on your phone: Settings -> Linked Devices -> Link a Device.'; } else if (whatsAppStatus.paired) { $waHelper.textContent = 'This WhatsApp account is already paired locally. Reconnect to resume live WhatsApp sync.'; + } else if (waPairingExpired) { + $waHelper.textContent = 'The pairing window expired before your phone confirmed. Click Connect WhatsApp for a fresh QR code, or link with a phone number below (no scanning needed).'; } else { $waHelper.textContent = 'Link a WhatsApp companion device for live WhatsApp sync, typing updates, and text replies inside OpenMessage.'; } @@ -12146,7 +12237,18 @@

Forward message

$waQRCodeCaption.textContent = ''; } - if (whatsAppStatus.last_error && !whatsAppStatus.pairing) { + // Phone-number pairing only applies while there is no local session. + if ($waPairCodeSection) { + $waPairCodeSection.hidden = whatsAppStatus.connected || whatsAppStatus.paired; + } + if (whatsAppStatus.connected && $waPairCodeResult) { + $waPairCodeResult.hidden = true; + if ($waPairCodeValue) $waPairCodeValue.textContent = ''; + } + + // "timeout" as a raw error string is cryptic; the expired-state helper + // text above already explains it. + if (whatsAppStatus.last_error && !whatsAppStatus.pairing && !waPairingExpired) { showWhatsAppError(whatsAppStatus.last_error); } @@ -12504,6 +12606,43 @@

Forward message

} } + function toggleWhatsAppPairCodeForm() { + if (!$waPairCodeForm) return; + $waPairCodeForm.hidden = !$waPairCodeForm.hidden; + if (!$waPairCodeForm.hidden && $waPairCodeInput) { + setTimeout(() => $waPairCodeInput.focus(), 60); + } + } + + async function requestWhatsAppPairCode() { + clearWhatsAppError(); + const phone = ($waPairCodeInput ? $waPairCodeInput.value : '').trim(); + if (!phone) { + showWhatsAppError('Enter your WhatsApp number in international format, e.g. +15551234567.'); + return; + } + if ($waPairCodeBtn) { $waPairCodeBtn.disabled = true; $waPairCodeBtn.textContent = 'Requesting…'; } + try { + const payload = await postJSON('/api/whatsapp/pair-code', { phone }); + if (payload && payload.status) { + applyAppStatus({ ...appStatus, whatsapp: payload.status }); + } + if (payload && payload.code && $waPairCodeValue && $waPairCodeResult) { + // WhatsApp shows the code in two dash-separated halves; mirror that. + const code = String(payload.code); + $waPairCodeValue.textContent = code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code; + $waPairCodeResult.hidden = false; + startWhatsAppPolling(); + } else { + showWhatsAppError('WhatsApp did not return a pairing code. Try again.'); + } + } catch (err) { + showWhatsAppError(err.message || 'Failed to request a WhatsApp pairing code'); + } finally { + if ($waPairCodeBtn) { $waPairCodeBtn.disabled = false; $waPairCodeBtn.textContent = 'Get code'; } + } + } + async function refreshSignalQRCode() { if (!$waOverlay.classList.contains('show') || !signalStatus.qr_available) return; try { @@ -13527,6 +13666,9 @@

Forward message

if ($waClose) $waClose.addEventListener('click', closeWhatsAppOverlay); if ($waConnectBtn) $waConnectBtn.addEventListener('click', () => { connectWhatsAppBridge().catch(err => showWhatsAppError(err.message || 'Failed to connect WhatsApp')); }); if ($waResetBtn) $waResetBtn.addEventListener('click', () => { resetWhatsAppBridge().catch(err => showWhatsAppError(err.message || 'Failed to reset WhatsApp')); }); + if ($waPairCodeToggle) $waPairCodeToggle.addEventListener('click', toggleWhatsAppPairCodeForm); + if ($waPairCodeBtn) $waPairCodeBtn.addEventListener('click', () => { requestWhatsAppPairCode().catch(err => showWhatsAppError(err.message || 'Failed to request pairing code')); }); + if ($waPairCodeInput) $waPairCodeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); requestWhatsAppPairCode().catch(err => showWhatsAppError(err.message || 'Failed to request pairing code')); } }); if ($signalConnectBtn) $signalConnectBtn.addEventListener('click', () => { connectSignalBridge().catch(err => showSignalError(err.message || 'Failed to connect Signal')); }); if ($signalResetBtn) $signalResetBtn.addEventListener('click', () => { resetSignalBridge().catch(err => showSignalError(err.message || 'Failed to reset Signal')); }); if ($gmReconnectBtn) $gmReconnectBtn.addEventListener('click', () => { diff --git a/internal/whatsapplive/client.go b/internal/whatsapplive/client.go index 6f14a4c..ed9da1d 100644 --- a/internal/whatsapplive/client.go +++ b/internal/whatsapplive/client.go @@ -50,8 +50,8 @@ var uploadMedia = func(cli *whatsmeow.Client, ctx context.Context, plaintext []b return cli.Upload(ctx, plaintext, mediaType) } -var downloadMediaWithPath = func(cli *whatsmeow.Client, ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength int, mediaType whatsmeow.MediaType, fileEncSHA256B64 string) ([]byte, error) { - return cli.DownloadMediaWithPath(ctx, directPath, encFileHash, fileHash, mediaKey, fileLength, mediaType, fileEncSHA256B64) +var downloadMediaWithPath = func(cli *whatsmeow.Client, ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, mediaType whatsmeow.MediaType, mmsType string, allowNoHash bool) ([]byte, error) { + return cli.DownloadMediaWithPath(ctx, directPath, encFileHash, fileHash, mediaKey, mediaType, mmsType, allowNoHash) } var connectClient = func(cli *whatsmeow.Client) error { @@ -209,7 +209,11 @@ func (b *Bridge) initClientLocked() error { container.Close() return fmt.Errorf("load WhatsApp device store: %w", err) } - cli := whatsmeow.NewClient(deviceStore, waLog.Noop) + // Route whatsmeow's internal logs through the bridge logger. Pairing + // failures in particular (code-pair notification decrypt, stream errors) + // are log-only — with a Noop logger they are invisible and a failed pair + // just silently returns to idle. + cli := whatsmeow.NewClient(deviceStore, waLog.Zerolog(b.logger.With().Str("component", "whatsmeow").Logger())) cli.EnableAutoReconnect = true cli.InitialAutoReconnect = true cli.AddEventHandler(b.handleEvent) @@ -349,6 +353,73 @@ func (b *Bridge) Connect() error { return nil } +// pairPhoneDisplayName is the linked-device name sent during phone pairing. +// WhatsApp's server validates this against a `Browser (OS)` allowlist and +// returns "400 bad-request" for anything it doesn't recognize (e.g. a product +// name like "OpenMessage"), so it must be a real browser/OS pair. It matches +// PairClientChrome below and is what shows on the phone's linked-devices list. +const pairPhoneDisplayName = "Chrome (macOS)" + +// PairPhone begins phone-number pairing: it connects the unpaired client and +// asks WhatsApp for an 8-character linking code the user types into their phone +// (WhatsApp > Linked devices > "Link with phone number instead"). Unlike the QR +// flow this has no ~2-minute scan window and needs no camera, so it survives +// coordination delays and can be driven from a text surface. Pairing completes +// over the same connection through the normal PairSuccess -> Connected events. +// phone must be in international format (a leading + is fine; it is stripped). +func (b *Bridge) PairPhone(phone string) (string, error) { + b.mu.Lock() + if err := b.recoverPersistedSessionLocked(); err != nil { + b.mu.Unlock() + return "", err + } + if b.client == nil { + b.mu.Unlock() + return "", fmt.Errorf("WhatsApp client unavailable") + } + if b.client.Store.ID != nil { + b.mu.Unlock() + return "", fmt.Errorf("WhatsApp is already paired") + } + cli := b.client + needConnect := !clientIsConnected(cli) + b.lastError = "" + b.pairing = true + b.connecting = true + b.qr = QRSnapshot{} + b.mu.Unlock() + b.emitStatusChange() + + clearPairingState := func(msg string) { + b.mu.Lock() + if b.client == cli { + b.pairing = false + b.connecting = false + b.lastError = msg + } + b.mu.Unlock() + b.emitStatusChange() + } + + if needConnect { + if err := connectClient(cli); err != nil && !errors.Is(err, whatsmeow.ErrAlreadyConnected) { + clearPairingState(err.Error()) + return "", fmt.Errorf("connect WhatsApp: %w", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + code, err := cli.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, pairPhoneDisplayName) + if err != nil { + clearPairingState(err.Error()) + return "", fmt.Errorf("request WhatsApp pairing code: %w", err) + } + // Leave pairing=true: handlePairSuccess / handleConnected clear it once the + // user enters the code on their phone. + return code, nil +} + func (b *Bridge) runConnect(cli *whatsmeow.Client) { if err := connectClient(cli); err != nil { b.logger.Warn().Err(err).Msg("WhatsApp connect failed") @@ -919,7 +990,9 @@ func (b *Bridge) DownloadStoredMedia(msg *db.Message) ([]byte, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - data, err := downloadMediaWithPath(cli, ctx, ref.DirectPath, fileEncSHA256, fileSHA256, mediaKey, int(ref.FileLength), mediaType, "") + // allowNoHash preserves the pre-upgrade lenient behavior for stored refs + // that lack a plaintext hash; when the hash is present it is verified. + data, err := downloadMediaWithPath(cli, ctx, ref.DirectPath, fileEncSHA256, fileSHA256, mediaKey, mediaType, "", len(fileSHA256) == 0) if err != nil { return nil, "", fmt.Errorf("download WhatsApp media: %w", err) } @@ -1061,6 +1134,14 @@ func (b *Bridge) handleEvent(raw any) { b.handlePairSuccess(evt) case *waevents.PairError: b.handlePairError(evt.Error) + case *waevents.PairPasskeyRequest: + // The account has a passkey, and passkey-verified code linking needs a + // companion-side WebAuthn assertion we cannot produce (the passkey + // lives on the user's phone/keychain). Without this the pairing just + // idles out silently and the phone shows "couldn't link device". + b.handlePairError(fmt.Errorf("this WhatsApp account is protected by a passkey, which phone-number linking doesn't support yet — scan the QR code instead")) + case *waevents.PairPasskeyError: + b.handlePairError(fmt.Errorf("passkey pairing failed: %w", evt.Error)) case *waevents.QRScannedWithoutMultidevice: b.handlePairError(fmt.Errorf("scan the QR code again after enabling multi-device support in WhatsApp")) case *waevents.Message: @@ -1080,6 +1161,17 @@ func (b *Bridge) handleEvent(raw any) { func (b *Bridge) handleConnected() { b.mu.Lock() + // An unpaired connection is only the transport we use to drive QR / phone- + // code pairing — it is not a usable session. Keep the pairing state (and the + // pairing affordance in the UI) alive until PairSuccess sets Store.ID; a + // premature connected=true would flip the UI to "Connected" and hide the + // code the user still needs to enter. + if b.client != nil && b.client.Store != nil && b.client.Store.ID == nil { + b.connecting = false + b.mu.Unlock() + b.emitStatusChange() + return + } b.connected = true b.connecting = false b.pairing = false diff --git a/internal/whatsapplive/client_test.go b/internal/whatsapplive/client_test.go index 3ba415e..b10a784 100644 --- a/internal/whatsapplive/client_test.go +++ b/internal/whatsapplive/client_test.go @@ -1962,7 +1962,7 @@ func TestBridgeDownloadStoredMediaUsesEncodedReference(t *testing.T) { }() clientIsConnected = func(_ *whatsmeow.Client) bool { return true } - downloadMediaWithPath = func(_ *whatsmeow.Client, _ context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength int, mediaType whatsmeow.MediaType, _ string) ([]byte, error) { + downloadMediaWithPath = func(_ *whatsmeow.Client, _ context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, mediaType whatsmeow.MediaType, _ string, allowNoHash bool) ([]byte, error) { if directPath != "/mms/image" { t.Fatalf("direct path = %q, want /mms/image", directPath) } @@ -1975,8 +1975,8 @@ func TestBridgeDownloadStoredMediaUsesEncodedReference(t *testing.T) { if string(mediaKey) != string([]byte{0x01, 0x02}) { t.Fatalf("media key = %x, want 0102", mediaKey) } - if fileLength != 9 { - t.Fatalf("file length = %d, want 9", fileLength) + if allowNoHash { + t.Fatal("allowNoHash = true, want false when a plaintext hash is present") } if mediaType != whatsmeow.MediaImage { t.Fatalf("media type = %v, want image", mediaType)