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 @@
Enter your WhatsApp number in international format, then on your phone open WhatsApp → Linked devices → Link a device → Link with phone number instead, and type the code shown here.
+