vojo/apps/widget-whatsapp/README.md

8 KiB
Raw Blame History

@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:

# 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:

# 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

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

"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).