Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions cmd/e2e-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
},
Expand Down
1 change: 1 addition & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions docs/agent-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions e2e/ui.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
8 changes: 8 additions & 0 deletions internal/app/whatsapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions internal/web/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions internal/web/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading