# @vojo/widget-whatsapp

Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
in the Vojo client.

This is **not** a WhatsApp client. It's a control panel for the
mautrix-whatsapp bridge that talks to the bridge's **provisioning HTTP
API** (bridgev2 `/_matrix/provision/v3/*`, exposed by Caddy at
`https://vojo.chat/_provision/whatsapp`). It links the account (QR scan
or an 8-character pairing code typed into the WhatsApp app), shows the
linked account, lists WhatsApp contacts, resolves +phone numbers, and
creates DM portals on demand.

Auth: the widget requests MSC1960 OpenID credentials from the host
(`get_openid` over the widget API; granted by `BotWidgetDriver.askOpenID`
when config.json opts the bot into the `vojo.openid` capability) and
sends them to the bridge as `Authorization: Bearer openid:<token>`. The
OpenID token only proves identity — it is not a Matrix access token.

There is no bot text-command transport and no reply parsing: the legacy
`!wa`-command dialect (`bridge-protocol/`) was deleted when the bridge
API became reachable. The bot control DM still exists (BotShell needs a
room and the «Show chat» fallback), the widget just doesn't read or
write it — it requests **zero** MSC2762 capabilities.

## WhatsApp-specific contract notes

Extracted from mautrix-whatsapp v0.2604.0 (`pkg/connector/login.go`,
`startchat.go`) + mautrix-go v0.27.0 (`bridgev2/matrix/provisioning.go`):

- Login flows: `qr` and `phone`. The phone flow's submit answers with a
  `display_and_wait` step of type **`code`** — an `XXXX-XXXX` pairing
  code the user types into the WhatsApp app (NOT an SMS OTP entered into
  the widget like Telegram's flow).
- The login window is ~160 s: whatsmeow issues ~6 QR tokens (60 s + 5×20 s)
  and closes the socket when they run out; the pairing code lives in the
  same window.
- There are **no** `.incorrect` retry steps: every rejected input is a
  `FI.MAU.WHATSAPP.*` RespError that deletes the login process
  server-side. The phone form transparently starts a fresh process on
  resubmit.
- Login-liveness semantics differ by bridge generation. mautrix ≤ v0.28.0:
  the login process dies with the poll's request context (and there is
  **no** `/v3/login/cancel` route — cancelling works by aborting the
  long-poll). mautrix ≥ v0.28.1 (`provisioninglogin.go`): the process
  lives server-side with a 30-minute TTL, dropped polls reattach, and the
  cancel route exists. The widget's wait loop is written for both: it
  retries transport-shaped poll failures with backoff (Android freezes
  the backgrounded WebView while the user enters the pairing code in
  WhatsApp), wakes on return to foreground, and disambiguates a late
  404 via whoami (the login may have COMPLETED while frozen).
- Identifiers are phone numbers only (`tel:+…`); no usernames. Contact
  ids are bare phone digits (or `lid-`/`bot-` prefixed for
  hidden-number/bot peers).

The About card and modal carry the **Meta-ToS risk disclosure**
(`warning.*` keys, amber warn styling, triangle icon) — WhatsApp's terms
of service forbid third-party clients and Meta may ban accounts for it.
This copy is a deliberate product/legal requirement: keep it intact when
restyling.

## Layout

```
src/
├── bootstrap.ts     Parse URL params the host appends (matches BotWidgetEmbed.ts)
├── widget-api.ts    Inline matrix-widget-api postMessage transport: handshake,
│                    theme, MSC1960 get_openid, io.vojo.bot-widget verbs
├── provisioning.ts  Typed client for the bridgev2 provisioning API + identifier
│                    helpers (wire contract documented in the file header)
├── errors.ts        Bridge/WhatsApp errcode → localized copy mapping
├── login.tsx        Login flow over the v3 step machine + forms (phone form,
│                    QR panel and pairing-code panel with long-poll)
├── contacts.tsx     Contacts list, search-as-filter, phone probe, create DM
├── App.tsx          Shell: boot/disconnected/connected phases, account view,
│                    About modal with the Meta-ToS warning callout
├── ui.tsx           Icons, initials avatar, command cards, notices
├── main.tsx         Entry: init bootstrap, render App or diagnostic
└── styles.css       Telegram-widget stylesheet verbatim + WhatsApp-only
                     additions (warn card, ToS callout, pairing-code plate)
```

## Local development

**Don't touch the committed `config.json`.** Create `config.local.json` at
the project root once — gitignored, never deployed. The host's Vite dev
server overlays it on top of `/config.json` responses (see
`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the
overlay entirely.

Note the overlay merges bot entries **shallowly** — your local
`experience` object replaces the base one wholesale, so it must carry
`provisioningUrl` and `capabilities` too:

```bash
# one-time: install widget deps
cd apps/widget-whatsapp && npm install

# one-time: create config.local.json (gitignored) at the project root
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
{
  "bots": [
    {
      "id": "whatsapp",
      "experience": {
        "type": "matrix-widget",
        "url": "http://localhost:8083/",
        "provisioningUrl": "https://vojo.chat/_provision/whatsapp",
        "capabilities": ["vojo.openid"]
      }
    }
  ]
}
JSON
```

Run both servers:

```bash
# terminal 1 — widget on :8083 with HMR
cd apps/widget-whatsapp && npm run dev

# terminal 2 — host SPA on :8080
cd /home/ubuntu/projects/vojo/cinny && npm start
```

Open `http://localhost:8080/bots/whatsapp`. Iframe loads cross-origin
from the widget dev server, HMR works, no proxy. The provisioning calls
go straight to the prod bridge API (CORS `*` + per-request bearer auth),
acting on whatever account you're signed in with — same trust model as
the dev client talking to the prod homeserver.

`http://localhost:*` URLs are accepted by the host's URL validator only
in dev builds (`import.meta.env.DEV` branch in
`src/app/features/bots/catalog.ts`); production builds drop the branch
via Vite's dead-code elimination, AND production-only enforces an origin
allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even
if config.json is poisoned.

## Build

```bash
npm run build
```

Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*`
into `~/vojo/widgets/whatsapp/` on the production host (Caddy serves
this via the `widgets.vojo.chat` block) — the VSCode task
`Deploy widgets` does all three widgets.

## Server-side requirements

1. The widget static files at `widgets.vojo.chat/whatsapp/` (Caddy
   `handle_path /whatsapp/*` block).
2. The bridge provisioning API exposed at the URL configured in
   config.json `experience.provisioningUrl`. Caddy block inside the
   `vojo.chat` site, next to the telegram one:

   ```
   handle /_provision/whatsapp/* {
       uri replace /_provision/whatsapp /_matrix/provision
       reverse_proxy whatsapp-bridge:29318   # host:port from appservice.address
   }
   ```

   Path-scoped on purpose: the same bridge listener serves the appservice
   transaction endpoints (`/_matrix/app/*`), which must stay internal.
3. `provisioning.shared_secret` in the bridge config must NOT be
   `disable` (a ≥16-char secret enables the API; the widget never sees
   the secret — it authenticates with per-user OpenID tokens).

## Updating the production /config.json

```json
"experience": {
  "type": "matrix-widget",
  "url": "https://widgets.vojo.chat/whatsapp/index.html",
  "provisioningUrl": "https://vojo.chat/_provision/whatsapp",
  "capabilities": ["vojo.openid"]
}
```

## Capability contract

The widget requests **no** MSC2762 capabilities — the handshake replies
with only `org.matrix.msc4039.download_file` (avatar thumbnails). The
privileged surfaces are:

- MSC1960 `get_openid` — granted by `BotWidgetDriver.askOpenID` iff
  config.json declares `"capabilities": ["vojo.openid"]` for this bot;
- `io.vojo.bot-widget` side-channel verbs `open-external-url` and
  `open-matrix-to` (origin-pinned and validated host-side in
  `BotWidgetEmbed.onWidgetMessage`).
