193 lines
8 KiB
Markdown
193 lines
8 KiB
Markdown
# @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`).
|