feat(whatsapp): rewrite the widget onto the bridge provisioning API with OpenID auth, a pairing-code panel, and background-resilient login polling

This commit is contained in:
heaven 2026-06-12 00:46:25 +03:00
parent 5c67c4d78e
commit 0095df358c
21 changed files with 3715 additions and 4164 deletions

4
apps/widget-whatsapp/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.local

View file

@ -1,84 +1,114 @@
# @vojo/widget-whatsapp # @vojo/widget-whatsapp
Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp` Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
in the Vojo client. Drives the mautrix-whatsapp bridge bot in the Vojo client.
(`@whatsappbot:vojo.chat`) by sending bridgev2 commands in the control DM
and rendering the bot's text replies into a typed login flow.
This is **not** a WhatsApp client — Vojo continues using the Matrix room This is **not** a WhatsApp client. It's a control panel for the
the bridge writes to. The widget is a panel that handles authentication mautrix-whatsapp bridge that talks to the bridge's **provisioning HTTP
(QR scan or pairing code) and surfaces session status. 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 ## Layout
``` ```
src/ src/
├── bootstrap.ts Parse URL params the host appends (mirrors BotWidgetEmbed.ts) ├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts)
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK) ├── widget-api.ts Inline matrix-widget-api postMessage transport: handshake,
├── App.tsx UI: login forms, QR / pairing-code panels, transcript pane │ theme, MSC1960 get_openid, io.vojo.bot-widget verbs
├── main.tsx Entry: init bootstrap, render App or diagnostic ├── provisioning.ts Typed client for the bridgev2 provisioning API + identifier
├── styles.css Theme-aware CSS (Vojo Dawn palette) │ helpers (wire contract documented in the file header)
├── state.ts Login state machine + hydrate-from-timeline ├── errors.ts Bridge/WhatsApp errcode → localized copy mapping
├── i18n/ Russian primary + English fallback ├── login.tsx Login flow over the v3 step machine + forms (phone form,
└── bridge-protocol/ │ QR panel and pairing-code panel with long-poll)
├── types.ts LoginEvent discriminated union ├── contacts.tsx Contacts list, search-as-filter, phone probe, create DM
├── parser.ts Dispatch shim ├── App.tsx Shell: boot/disconnected/connected phases, account view,
└── dialects/ │ About modal with the Meta-ToS warning callout
└── bridgev2_v0264.ts Regex table pinned to mautrix-whatsapp v0.26.4 ├── 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)
``` ```
## Login flows
WhatsApp's mautrix bridge ships TWO login flows (see
`pkg/connector/login.go::GetLoginFlows`):
1. **QR** (`!wa login qr`) — bridge emits a rotating `m.image` whose body
is the raw whatsmeow handshake payload (`<ref>,<noise>,<identity>,<adv>`,
four base64 fields). The widget renders it as a QR matrix client-side.
Whatsmeow `qrIntervals = [60s, 20s, 20s, 20s, 20s, 20s]` — first QR
lasts 60 seconds, then five rotations of 20 seconds each. Total active
window: 2 minutes 40 seconds. Each rotation arrives as an `m.replace`
edit of the original event; the state machine matches on the original
id and repaints the matrix.
2. **Pairing code** (`!wa login phone`) — alternative for users whose
camera doesn't work or who prefer typing. The user enters a phone
number; the bridge replies with two notices:
- `Input the pairing code in the WhatsApp mobile app to log in`
- The 8-character code itself (`XXXX-XXXX`, custom base32 alphabet).
The widget renders the code prominently and the user enters it in
WhatsApp → Settings → Linked devices → Link with phone number.
There is **no 2FA cloud-password step** — multidevice handshake is
single-factor. The state machine has no `awaiting_password` arm.
## Capability contract
The widget requests EXACTLY this set (matches the host's
`BotWidgetDriver.getBotWidgetCapabilities`):
```
org.matrix.msc2762.timeline:<roomId>
org.matrix.msc2762.send.event:m.room.message#m.text
org.matrix.msc2762.receive.event:m.room.message#m.text
org.matrix.msc2762.receive.event:m.room.message#m.notice
org.matrix.msc2762.receive.event:m.room.message#m.image
org.matrix.msc2762.receive.event:m.room.redaction
org.matrix.msc2762.receive.state_event:m.room.member
```
Anything else is silently dropped by the host. The capability set is
identical to the Telegram widget's M13 expansion — the host driver
already supports `m.image` + `m.room.redaction`.
## Local development ## 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 ```bash
# one-time: install widget deps
cd apps/widget-whatsapp && npm install cd apps/widget-whatsapp && npm install
cd /home/ubuntu/projects/vojo/cinny && cat > config.local.json <<'JSON'
# one-time: create config.local.json (gitignored) at the project root
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
{ {
"bots": [ "bots": [
{ "id": "whatsapp", "experience": { "type": "matrix-widget", "url": "http://localhost:8083/" } } {
"id": "whatsapp",
"experience": {
"type": "matrix-widget",
"url": "http://localhost:8083/",
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
"capabilities": ["vojo.openid"]
}
}
] ]
} }
JSON JSON
@ -94,8 +124,18 @@ cd apps/widget-whatsapp && npm run dev
cd /home/ubuntu/projects/vojo/cinny && npm start cd /home/ubuntu/projects/vojo/cinny && npm start
``` ```
Open `http://localhost:8080/bots/whatsapp`. The host's URL validator Open `http://localhost:8080/bots/whatsapp`. Iframe loads cross-origin
accepts `http://localhost:*` only in dev builds. 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 ## Build
@ -103,35 +143,51 @@ accepts `http://localhost:*` only in dev builds.
npm run build npm run build
``` ```
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` into Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*`
`~/vojo/widgets/whatsapp/` on the production host. The VSCode task into `~/vojo/widgets/whatsapp/` on the production host (Caddy serves
`Deploy widgets` already includes the third subshell — running it from this via the `widgets.vojo.chat` block) — the VSCode task
the host root pushes all three widgets in sequence. `Deploy widgets` does all three widgets.
## Capacitor (Android) ## Server-side requirements
`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing 1. The widget static files at `widgets.vojo.chat/whatsapp/` (Caddy
TG / Discord widgets — no extra entry needed for WhatsApp. `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:
## Hosting (server-side) ```
handle /_provision/whatsapp/* {
uri replace /_provision/whatsapp /_matrix/provision
reverse_proxy whatsapp-bridge:29318 # host:port from appservice.address
}
```
Same Caddy `widgets.vojo.chat` block as the other widgets — add a third Path-scoped on purpose: the same bridge listener serves the appservice
`handle_path /whatsapp/* { … }` block alongside `/telegram/*` and transaction endpoints (`/_matrix/app/*`), which must stay internal.
`/discord/*`. Then `mkdir -p ~/vojo/widgets/whatsapp` on the server, run 3. `provisioning.shared_secret` in the bridge config must NOT be
the deploy task, and verify with `disable` (a ≥16-char secret enables the API; the widget never sees
`curl -I https://widgets.vojo.chat/whatsapp/index.html`. the secret — it authenticates with per-user OpenID tokens).
## Source-of-truth pointers ## Updating the production /config.json
- mautrix-whatsapp connector: <https://github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go> ```json
- mautrix-whatsapp connector (post-login session events): "experience": {
<https://github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go> "type": "matrix-widget",
- whatsmeow QR format: <https://github.com/tulir/whatsmeow/blob/main/pair.go> (`makeQRData`) "url": "https://widgets.vojo.chat/whatsapp/index.html",
- whatsmeow pairing-code: <https://github.com/tulir/whatsmeow/blob/main/pair-code.go> (`PairPhone`) "provisioningUrl": "https://vojo.chat/_provision/whatsapp",
- bridgev2 commands layer (shared with mautrix-telegram): "capabilities": ["vojo.openid"]
<https://github.com/mautrix/go/blob/main/bridgev2/commands/login.go> }
```
The dialect file `src/bridge-protocol/dialects/bridgev2_v0264.ts` has ## Capability contract
inline upstream pointers per regex; when the bridge image is upgraded,
spot-check those pointers and either confirm the wording is still valid The widget requests **no** MSC2762 capabilities — the handshake replies
or drop a sibling dialect file with new regexes. 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`).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
// Avatar loader on top of the host's MSC4039 download_file. Module-level
// cache (mxc → objectURL promise) so a contact list re-render or tab switch
// never re-downloads, plus a small concurrency gate so opening a 200-contact
// list doesn't fire 200 parallel postMessage round-trips at once.
//
// Object URLs are kept for the iframe's lifetime — the widget document dies
// with the bot page, and the blobs are 96px thumbnails, so there's nothing
// worth revoking eagerly.
import { useEffect, useState } from 'preact/hooks';
import type { WidgetApi } from './widget-api';
const cache = new Map<string, Promise<string | null>>();
const MAX_CONCURRENT_DOWNLOADS = 4;
let active = 0;
const waiters: Array<() => void> = [];
const acquireSlot = async (): Promise<void> => {
if (active >= MAX_CONCURRENT_DOWNLOADS) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
active += 1;
};
const releaseSlot = (): void => {
active -= 1;
waiters.shift()?.();
};
// Failed downloads stay cached only briefly: long enough that one list
// render can't hammer a dead media endpoint, short enough that a transient
// network blip doesn't pin initials for the rest of the session.
const FAILURE_RETRY_MS = 60_000;
const loadAvatar = async (api: WidgetApi, mxc: string): Promise<string | null> => {
await acquireSlot();
try {
const blob = await api.downloadFile(mxc);
return URL.createObjectURL(blob);
} catch {
// Missing media / capability denied / network — initials fallback now,
// retry possible after the negative-cache window.
window.setTimeout(() => cache.delete(mxc), FAILURE_RETRY_MS);
return null;
} finally {
releaseSlot();
}
};
const resolveAvatar = (api: WidgetApi, mxc: string): Promise<string | null> => {
let promise = cache.get(mxc);
if (!promise) {
promise = loadAvatar(api, mxc);
cache.set(mxc, promise);
}
return promise;
};
/** Resolve an mxc avatar URI to a local object URL (null while loading or on
* failure callers render the initials fallback in both cases). */
export const useMxcAvatar = (api: WidgetApi, mxc: string | undefined): string | null => {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!mxc || !mxc.startsWith('mxc://')) {
setUrl(null);
return undefined;
}
let alive = true;
setUrl(null);
resolveAvatar(api, mxc).then((resolved) => {
if (alive) setUrl(resolved);
});
return () => {
alive = false;
};
}, [api, mxc]);
return url;
};
/** Like useMxcAvatar, but walks a PRIORITY LIST of candidate mxc URIs and
* returns the first one that actually downloads. Built for the own-profile
* avatar, where the primary source (whoami profile.avatar) can be empty OR
* point at media that no longer resolves a dead first candidate must not
* mask a live second one. */
export const useFirstAvatar = (
api: WidgetApi,
candidates: Array<string | undefined>
): string | null => {
const [url, setUrl] = useState<string | null>(null);
const key = candidates.filter(Boolean).join('|');
useEffect(() => {
let alive = true;
setUrl(null);
const list = key === '' ? [] : key.split('|');
void (async () => {
for (const mxc of list) {
if (!mxc.startsWith('mxc://')) continue;
// Sequential on purpose: candidates are ordered by trustworthiness
// and the list is ≤3 entries, all cached after the first pass.
// eslint-disable-next-line no-await-in-loop
const resolved = await resolveAvatar(api, mxc);
if (!alive) return;
if (resolved) {
setUrl(resolved);
return;
}
}
})();
return () => {
alive = false;
};
}, [api, key]);
return url;
};

View file

@ -1,5 +1,5 @@
// Parse the URL params the host appends when loading experience.url. // Parse the URL params the bot widget host appends when loading
// Source of truth on the host side: // experience.url. Source of truth on the host side:
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl). // src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
// Keep this in sync if the host adds params. // Keep this in sync if the host adds params.
@ -11,14 +11,12 @@ export type WidgetBootstrap = {
userId: string; userId: string;
botId: string; botId: string;
botMxid: string; botMxid: string;
/** Bridge command prefix (e.g. `!wa`). Always non-empty the host /** Base URL of the bridge provisioning HTTP API (bridgev2
* validator (catalog.ts) defaults missing values to `!tg` and rejects * `/_matrix/provision` mount behind the reverse proxy), e.g.
* malformed overrides. The widget prepends `<commandPrefix> ` to every * `https://vojo.chat/_provision/whatsapp`. Empty string when the host
* outbound command and form-field value (bridgev2/queue.go:118 strips * config hasn't exposed it the App renders a config-required notice
* exactly `prefix+" "`). For mautrix-whatsapp the operator must set * instead of booting the transport. */
* `commandPrefix: "!wa"` in /config.json connector.go ships provisioningUrl: string;
* `DefaultCommandPrefix: "!wa"`. */
commandPrefix: string;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
clientLanguage: string; clientLanguage: string;
}; };
@ -27,7 +25,7 @@ export type BootstrapResult =
| { ok: true; bootstrap: WidgetBootstrap } | { ok: true; bootstrap: WidgetBootstrap }
| { ok: false; missing: string[] }; | { ok: false; missing: string[] };
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const; const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
export const readBootstrap = (search: string): BootstrapResult => { export const readBootstrap = (search: string): BootstrapResult => {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
@ -46,6 +44,27 @@ export const readBootstrap = (search: string): BootstrapResult => {
return { ok: false, missing: ['parentUrl'] }; return { ok: false, missing: ['parentUrl'] };
} }
// The host validator (catalog.ts normalizeProvisioningUrl) already
// enforces https + no embedded credentials; re-parse defensively anyway
// because this is the widget's fetch target. Malformed → '' → the App
// shows the config-required notice rather than fetching a garbage URL.
let provisioningUrl = '';
const rawProvisioning = get('provisioningUrl').trim();
if (rawProvisioning) {
try {
const parsed = new URL(rawProvisioning);
if (
!parsed.username &&
!parsed.password &&
(parsed.protocol === 'https:' || (import.meta.env.DEV && parsed.protocol === 'http:'))
) {
provisioningUrl = parsed.toString().replace(/\/+$/, '');
}
} catch {
/* keep '' */
}
}
const themeRaw = get('theme'); const themeRaw = get('theme');
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light'; const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
@ -59,7 +78,7 @@ export const readBootstrap = (search: string): BootstrapResult => {
userId: get('userId'), userId: get('userId'),
botId: get('botId'), botId: get('botId'),
botMxid: get('botMxid'), botMxid: get('botMxid'),
commandPrefix: get('commandPrefix'), provisioningUrl,
theme, theme,
clientLanguage: get('clientLanguage'), clientLanguage: get('clientLanguage'),
}, },

View file

@ -1,849 +0,0 @@
// Dialect: mautrix-whatsapp v0.26.4 (16 Apr 2026) on bridgev2 framework.
// Generated against connector + bridgev2 commit hashes current as of
// research date 2026-05-05.
//
// Each regex below is paired with its upstream source line. If wording
// drifts in a future patch, replace this file with a sibling
// `bridgev2_v0265.ts` (or whatever) and switch the import in
// ../parser.ts.
//
// Body encoding note: bridgev2 routes replies through `format.RenderMarkdown`
// (bridgev2/commands/event.go). Our host driver strips `formatted_body`
// (Phase 2 contract), so the widget only ever sees the markdown source —
// backticks, asterisks, escaped angle-brackets stay literal.
//
// === Upstream pointers (verified 2026-05-05) ===
//
// SHARED bridgev2 commands (identical to mautrix-telegram dialect):
// github.com/mautrix/go/blob/main/bridgev2/commands/login.go
// - Phone field prompt: line 207 (UserInput → "Please enter your <Name>")
// - list-logins reply: user.go:185-190 ("\n* `<id>` (<Name>) - `<state>`")
// - logout reply: commands/login.go:591 ("Logged out")
// - cancel replies: commands/processor.go:198/200
// ("Login cancelled.", "No ongoing command.")
// - login_in_progress: commands/login.go:83
// ("You already have an ongoing login...")
// - max_logins: commands/login.go:74-79
// ("You have reached the maximum number of logins (N)")
// - login_not_found: commands/login.go:587/68 ("Login `id` not found")
// - flow_required / invalid: commands/login.go:107/98
// - unknown_command: commands/processor.go:163
// - generic error traps: commands/login.go (Failed to ..., Login failed: ...)
// - login_failed display-and-wait branch:
// commands/login.go:366 ("Login failed: %v")
// - QR rendering as m.image: bridgev2/commands/login.go sendQR (`Body: qr`)
//
// CONNECTOR mautrix-whatsapp:
// github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go
// - Phone field name: "Phone number" + description
// "Your WhatsApp phone number in international format"
// - QR Instructions: "Scan the QR code with the WhatsApp mobile app to log in"
// - Code Instructions: "Input the pairing code in the WhatsApp mobile app to log in"
// - Login complete Instructions: fmt.Sprintf("Successfully logged in as %s", ul.RemoteName)
// where RemoteName = "+<phone-number>"
// - Connector errors (RespError values, surface via login_failed trap):
// CLIENT_OUTDATED: "Got client outdated error while waiting for QRs..."
// MULTIDEVICE_NOT_ENABLED: "Please enable WhatsApp web multidevice..."
// LOGIN_TIMEOUT: "Entering code or scanning QR timed out. Please try again."
// UNEXPECTED_EVENT: "Unexpected event while waiting for login"
// PHONE_NUMBER_TOO_SHORT: "Phone number too short"
// PHONE_NUMBER_NOT_INTERNATIONAL: "Phone number must be in international format"
// RATE_LIMITED: "Rate limited by WhatsApp"
// PAIR_ERROR: "<go-error from PairError event>"
//
// github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go
// - external logout: "You were logged out from another device. Relogin to..."
// "Your phone was logged out from WhatsApp. Relogin to..."
// "You were logged out for an unknown reason. Relogin to..."
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
// - connection: "Reconnecting to WhatsApp...", "Disconnected from WhatsApp. Trying to reconnect.",
// "Your phone hasn't been seen in over 12 days...",
// "The WhatsApp web servers are not responding...",
// "Connecting to the WhatsApp web servers failed.",
// "Stream replaced: the bridge was started in another location."
//
// QR PAYLOAD (whatsmeow):
// github.com/tulir/whatsmeow/blob/main/pair.go ::makeQRData
// strings.Join([]string{ref, noise, identity, adv}, ",")
// → 4 base64-ish fields separated by literal commas. NOT a URL.
//
// PAIRING CODE FORMAT (whatsmeow):
// github.com/tulir/whatsmeow/blob/main/pair-code.go ::PairPhone
// 8 chars from base32 alphabet "123456789ABCDEFGHJKLMNPQRSTVWXYZ"
// formatted as XXXX-XXXX (4 chars + "-" + 4 chars).
import type { LoginEvent, ListedLogin, ParsableEvent, ExternalLogoutReason } from '../types';
// --- Regex table — shared bridgev2 wording -------------------------------
// list-logins, empty: bridgev2/commands/login.go → `You're not logged in`.
// NO trailing period. Same as Telegram dialect — kept anchored just in case
// a future bridgev2 patch drifts.
const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
// list-logins, non-empty: bridgev2/user.go ships a leading `\n` due to a
// `make([]string, N) + append` bug. Each row is
// `* \`<id>\` (<RemoteName>) - \`<state>\``.
//
// For WhatsApp:
// <id> = JID-derived login id (digits, possibly digits.0)
// <RemoteName> = "+<phone-number>" (e.g. "+12345678901")
// <state> = state string ("CONNECTED" etc)
//
// Greedy `(.+)` capture for name backtracks to the LAST `)` before
// ` - `<state>`` — paranoid against future RemoteName drift even though
// WhatsApp's RemoteName is currently always `+<digits>`.
const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
// Phone prompt — bridgev2/commands/login.go composes
// `Please enter your <field.Name>\n<field.Description>`. Connector field
// is { Name: "Phone number", Description: "Your WhatsApp phone number in
// international format" } — but we anchor on the prefix only so that an
// upstream tweak to the description doesn't break detection.
const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
// Login success — bridgev2 renders Instructions as a plain reply. WhatsApp
// connector's success Instructions: `Successfully logged in as +<phone>`.
// Distinct from Telegram's `Successfully logged in as @handle (\`id\`)` —
// no parens, no numeric ID. Capture the handle (which IS the phone).
//
// Tolerate optional trailing period (bridgev2 doesn't add one but a future
// patch might) and optional surrounding whitespace.
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(\+?[\w.+-]+)\.?$/i;
// Logout — bridgev2/commands/login.go → `Logged out` (no period).
const LOGOUT_OK_RE = /^logged out\.?$/i;
// Cancel — bridgev2/commands/processor.go ::CommandCancel emits
// `Reply("%s cancelled.", action)` where `action` is the stored
// CommandState.Action. Today every WA login path uses Action="Login",
// so the rendered string is "Login cancelled." — but matching that
// literal would fail if a future bridgev2 ever introduces another
// action (e.g. "Logout"/"Relogin") that triggers this reply path.
// The relaxed pattern matches «<word> cancelled.» so the cancel-ok
// flow stays robust to the upstream wording shape, not its action
// name. Source: https://raw.githubusercontent.com/mautrix/go/main/bridgev2/commands/processor.go
const CANCEL_OK_RE = /^\S+ cancelled\.?$/i;
const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
// Login already in progress — bridgev2/commands/login.go.
const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
// Max logins — bridgev2/commands/login.go. Captures the limit.
const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
// Login id not found — bridgev2/commands/login.go (logout / relogin).
const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
// Flow selector errors — bridgev2/commands/login.go. WhatsApp returns
// `flow_required` for bare `!wa login` because GetLoginFlows returns 2
// flows. The widget always sends `login qr` / `login phone`, so this
// trap exists as defence-in-depth (e.g. the user typed `!wa login` in
// chat-fallback).
const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
// Unknown command — bridgev2/commands/processor.go.
const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
// Generic error traps. Each anchors on a distinct prefix.
const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
// `Login failed: %v` from doLoginDisplayAndWait Wait error path.
// All connector-side WhatsApp login errors funnel through here.
const LOGIN_FAILED_RE = /^login failed:\s*(.*)$/i;
// --- Regex table — connector-specific wording ----------------------------
// QR Instructions — connector login.go ::makeQRStep:
// `Scan the QR code with the WhatsApp mobile app to log in`.
//
// The widget doesn't strictly need to recognise this on its own (the
// m.image with the QR data is the operative signal for state transition),
// but emitting an `unknown` for it would litter the transcript with diag
// lines for every QR rotation. We swallow it as a discrete event so the
// state machine can ignore it without leaving it in transcript.
const QR_INSTRUCTIONS_RE = /^scan the qr code with the whatsapp mobile app\b/i;
// Pairing-code Instructions — connector login.go ::SubmitUserInput:
// `Input the pairing code in the WhatsApp mobile app to log in`. First
// of TWO bot replies after a phone-number submit on `!wa login phone`;
// the actual code lands in the next reply.
const PAIRING_CODE_INSTRUCTIONS_RE = /^input the pairing code in the whatsapp mobile app\b/i;
// Pairing code body — `XXXX-XXXX` from whatsmeow's PairPhone, rendered
// via bridgev2's ReplyAdvanced as `<code>XXXX-XXXX</code>` HTML. After
// `format.RenderMarkdown` (mautrix/go) routes through `HTMLToContent` →
// `SafeMarkdownCode` (format/markdown.go), the body field is ALWAYS
// the markdown-source `` `XXXX-XXXX` `` (backticks wrapped around the
// code). The earlier comment claimed «either plain or backticked» —
// in practice bridgev2 always emits the backticked form; the regex's
// `\`?` keeps the plain-form path tolerant for future framework
// changes that strip the wrapping.
// Character class follows whatsmeow's custom base32 alphabet
// `123456789ABCDEFGHJKLMNPQRSTVWXYZ` exactly: digits 1-9, uppercase
// letters minus I, O, U.
const PAIRING_CODE_RE = /^\s*`?([1-9A-HJ-NP-TV-Z]{4}-[1-9A-HJ-NP-TV-Z]{4})`?\s*$/;
// External-logout reasons — connector handlewhatsapp.go. Each anchors on
// the verbatim wording, captures nothing (the kind itself encodes the
// reason). Matching three classes:
// 1. Logged out from another device (multidevice unlink elsewhere).
// 2. Phone was logged out from WhatsApp (user logged out the WA app
// itself, which kills every linked device).
// 3. Logged out for an unknown reason (everything else, including
// "You're not logged into WhatsApp" idle-bridge case).
const LOGGED_OUT_FROM_ANOTHER_DEVICE_RE = /^you were logged out from another device\b/i;
const PHONE_LOGGED_OUT_RE = /^your phone was logged out from whatsapp\b/i;
const LOGGED_OUT_UNKNOWN_RE = /^you were logged out for an unknown reason\b/i;
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
// — emitted by the connector at startup if no session exists OR after a
// re-init that found no session. Treated as `external_logout{unknown}`
// because the visible result (need to re-login) is identical.
const NOT_LOGGED_INTO_WHATSAPP_RE = /^you'?re not logged into whatsapp\b/i;
// Connection warnings — connector handlewhatsapp.go. None of these mean
// the user has to do anything; surface in transcript only.
// `Connect failure: 405 client outdated. Bridge must be updated.` IS
// effectively a hard wall (no flow can succeed until the bridge image
// is upgraded), but surfacing it as a connection_warning rather than
// an `unknown` keeps the transcript readable; the user will see it
// alongside the eventual login_failed.
// `You're not connected to WhatsApp` is the human-readable label of
// the WANotConnected BridgeState code — it doesn't typically reach
// the management room as an m.notice, but match it just in case a
// future bridgev2 patch wires it into one.
const CONNECTION_WARNING_RES: RegExp[] = [
/^reconnecting to whatsapp/i,
/^disconnected from whatsapp\. trying to reconnect/i,
/^your phone hasn'?t been seen in over\b/i,
/^the whatsapp web servers are not responding\b/i,
/^connecting to the whatsapp web servers failed/i,
/^stream replaced: the bridge was started in another location/i,
/^connect failure: \d+\b/i,
/^you'?re not connected to whatsapp\b/i,
];
// --- Body parser ---------------------------------------------------------
const trimReplyBody = (raw: string): string => raw.trim();
const parseLoginList = (body: string): ListedLogin[] => {
const logins: ListedLogin[] = [];
// matchAll requires the global flag — rebuild the RegExp each call so
// the shared instance's lastIndex doesn't bleed between callers.
const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
for (const match of body.matchAll(re)) {
const [, id, name, state] = match;
logins.push({ id, name, state });
}
return logins;
};
const matchExternalLogout = (body: string): ExternalLogoutReason | undefined => {
if (LOGGED_OUT_FROM_ANOTHER_DEVICE_RE.test(body)) return 'another_device';
if (PHONE_LOGGED_OUT_RE.test(body)) return 'phone_logged_out';
if (LOGGED_OUT_UNKNOWN_RE.test(body)) return 'unknown';
if (NOT_LOGGED_INTO_WHATSAPP_RE.test(body)) return 'unknown';
return undefined;
};
const isConnectionWarning = (body: string): boolean =>
CONNECTION_WARNING_RES.some((re) => re.test(body));
export const parseBridgev2V0264Body = (rawBody: string): LoginEvent => {
const body = trimReplyBody(rawBody);
if (body.length === 0) return { kind: 'unknown' };
// Order: highly-specific terminal/transitional matches first, generic
// error traps last. The login-list parser comes early because its anchor
// (` * `<id>` `) wouldn't false-match anything else, and the alternative
// — `not_logged_in` — covers the empty-list case explicitly.
// Async session events (connector-emitted) — try BEFORE shared bridgev2
// patterns because `You're not logged into WhatsApp` wording overlaps
// partially with `You're not logged in` (NOT_LOGGED_IN_RE) — we need
// to win on the more specific trap.
const externalLogout = matchExternalLogout(body);
if (externalLogout) return { kind: 'external_logout', reason: externalLogout };
if (isConnectionWarning(body)) return { kind: 'connection_warning', text: body };
if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
const successMatch = LOGIN_SUCCESS_RE.exec(body);
if (successMatch) {
return {
kind: 'login_success',
handle: successMatch[1].trim(),
};
}
if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
// QR Instructions — discrete kind, swallowed by the state machine
// (the m.image carries the operative signal). MUST come BEFORE the
// pairing-code regex so the order is unambiguous.
if (QR_INSTRUCTIONS_RE.test(body)) return { kind: 'unknown' };
if (PAIRING_CODE_INSTRUCTIONS_RE.test(body)) return { kind: 'pairing_code_instructions' };
// Pairing code body — must be checked AFTER the various error traps
// because a Go-error tail could in theory contain an 8-char hyphenated
// sequence. In practice the upstream alphabet (1-9 + A-HJ-NP-TV-Z)
// doesn't overlap with timestamps or PII tokens, but order matters
// for defensiveness.
// Skip checking it here at the top — the ordered fall-through later
// catches it after error traps.
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
const maxMatch = MAX_LOGINS_RE.exec(body);
if (maxMatch) {
const limit = Number(maxMatch[1]);
return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
}
const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
const invalidValueMatch = INVALID_VALUE_RE.exec(body);
if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
const startFailedMatch = START_FAILED_RE.exec(body);
if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
// Pairing code body — checked AFTER all error traps so a Go-error tail
// matching the pattern by accident doesn't pre-empt a real error
// classification. The `^` anchor + character class is strict enough
// that false matches against arbitrary text are unlikely.
const pairingMatch = PAIRING_CODE_RE.exec(body);
if (pairingMatch) return { kind: 'pairing_code_displayed', code: pairingMatch[1] };
// Fall-through to login-list AFTER the error traps so a row that happens
// to start with `* ` mid-error-message doesn't get mistaken for a login
// list.
const logins = parseLoginList(body);
if (logins.length > 0) return { kind: 'logins_listed', logins };
return { kind: 'unknown' };
};
// --- Full-event parser ---------------------------------------------------
//
// `parseEventBridgev2V0264` dispatches on `event.type` and routes:
//
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
// redaction's `redacts` against the active QR event id and decides
// whether it's a meaningful signal or unrelated cleanup.
//
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
// contains a whatsmeow QR payload (4 comma-separated base64 fields).
//
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
// `parseBridgev2V0264Body(body)` path.
// Whatsmeow QR data: `<ref>,<base64-noise>,<base64-identity>,<base64-adv>`.
// Each field is alphanumeric + base64 fillers + a few extras commonly seen
// in `ref` (`@`, `:`, `.`, `-`, `_`). Match exactly 4 comma-separated
// non-empty alphanumeric chunks at the start of the string. NO leading
// whitespace tolerance because the bridge's `Body: qr` (sendQR in
// bridgev2/commands/login.go) is a clean assignment with no prefix.
//
// Strictness rationale: false-positives here are catastrophic — we'd
// emit a `qr_displayed` for an arbitrary text image caption, the state
// machine would render its body into a QR matrix, and the user would
// see a meaningless QR. The 4-field shape and the alphabet are tight
// enough to avoid that against any realistic m.image body.
const WA_QR_PAYLOAD_RE = /^[A-Za-z0-9+/=@:_.\-]+(?:,[A-Za-z0-9+/=@:_.\-]+){3}$/;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseEventBridgev2V0264 = (event: ParsableEvent): LoginEvent => {
if (event.type === 'm.room.redaction') {
// `redacts` is mirrored at the top level by the host sanitizer (see
// `sanitizeBotWidgetRedactionEvent` in BotWidgetDriver.ts), but check
// both spots for forward-compat with future drivers / SDK shapes.
const target =
typeof event.redacts === 'string'
? event.redacts
: isObject(event.content) && typeof event.content.redacts === 'string'
? event.content.redacts
: undefined;
if (!target) return { kind: 'unknown' };
return { kind: 'qr_redacted', redactsEventId: target };
}
if (event.type !== 'm.room.message') return { kind: 'unknown' };
const msgtype = event.content?.msgtype;
if (msgtype === 'm.image') {
// Edits replace `body` by spec; bridgev2 ALSO mirrors the new payload
// into `m.new_content.body`. Prefer `m.new_content.body` when present
// (so an older SDK pre-flattening edit content still lets us extract
// the rotated QR) and fall back to `body`.
const newContent = isObject(event.content['m.new_content'])
? (event.content['m.new_content'] as { body?: unknown })
: undefined;
const editedBody =
typeof newContent?.body === 'string' ? newContent.body : undefined;
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
const body = (editedBody ?? directBody).trim();
if (!WA_QR_PAYLOAD_RE.test(body)) return { kind: 'unknown' };
const relatesTo = isObject(event.content['m.relates_to'])
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
: undefined;
const replacesEventId =
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
? relatesTo.event_id
: undefined;
return {
kind: 'qr_displayed',
qrData: body,
eventId: event.event_id,
replacesEventId,
};
}
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
const body = typeof event.content.body === 'string' ? event.content.body : '';
return parseBridgev2V0264Body(body);
};
// --- DEV sanity assertions -----------------------------------------------
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
// is replaced with the literal `false` and the call site collapses, so the
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
// first regression on reload.
if (import.meta.env.DEV) {
runSanityChecks();
}
function runSanityChecks(): void {
const cases: Array<[string, LoginEvent]> = [
// Shared bridgev2 wordings (verified identical to mautrix-telegram).
["You're not logged in", { kind: 'not_logged_in' }],
["You're not logged in.", { kind: 'not_logged_in' }],
[
'Please enter your Phone number\nYour WhatsApp phone number in international format',
{ kind: 'awaiting_phone' },
],
// WhatsApp connector-side: success format has NO parens, NO numericId.
// Handle is the phone number with leading `+`.
[
'Successfully logged in as +12345678901',
{ kind: 'login_success', handle: '+12345678901' },
],
// Edge: trailing period, just in case bridgev2 ever adds one.
[
'Successfully logged in as +12345678901.',
{ kind: 'login_success', handle: '+12345678901' },
],
// Logout / cancel — same as Telegram dialect.
['Logged out', { kind: 'logout_ok' }],
['Login cancelled.', { kind: 'cancel_ok' }],
['No ongoing command.', { kind: 'cancel_no_op' }],
// Login-progress / max-logins / not-found — same as Telegram dialect.
[
'You already have an ongoing login. You can use `!wa cancel` to cancel it.',
{ kind: 'login_in_progress' },
],
[
'You have reached the maximum number of logins (1). Please logout from an existing login before creating a new one. If you want to re-authenticate an existing login, use the `!wa relogin` command.',
{ kind: 'max_logins', limit: 1 },
],
['Login `12345678901.0` not found', { kind: 'login_not_found', loginId: '12345678901.0' }],
['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }],
// flow_required / flow_invalid — bridgev2 emits these because WA
// has TWO flows (qr + phone). The widget sends the full command so
// these traps are defence-in-depth.
[
'Please specify a login flow, e.g. `login qr`.\n\n* `qr` - Scan a QR code...\n* `phone` - Input your phone number...\n',
{ kind: 'flow_required' },
],
[
'Invalid login flow `wat`. Available options:\n\n* `qr` - ...',
{ kind: 'flow_invalid', flowId: 'wat' },
],
// Generic error traps — same shape as Telegram dialect.
['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }],
[
'Failed to submit input: Phone number too short',
{ kind: 'submit_failed', reason: 'Phone number too short' },
],
[
'Failed to prepare login process: connector unavailable',
{ kind: 'prepare_failed', reason: 'connector unavailable' },
],
[
'Failed to start login: whatsapp connect timeout',
{ kind: 'start_failed', reason: 'whatsapp connect timeout' },
],
// Connector login-failed surfacings (verified upstream — every
// RespError listed in pkg/connector/login.go funnels through here).
[
'Login failed: Phone number too short',
{ kind: 'login_failed', reason: 'Phone number too short' },
],
[
'Login failed: Phone number must be in international format',
{ kind: 'login_failed', reason: 'Phone number must be in international format' },
],
[
'Login failed: Rate limited by WhatsApp',
{ kind: 'login_failed', reason: 'Rate limited by WhatsApp' },
],
[
'Login failed: Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
{
kind: 'login_failed',
reason:
'Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
},
],
[
'Login failed: Please enable WhatsApp web multidevice and scan the QR code again.',
{
kind: 'login_failed',
reason: 'Please enable WhatsApp web multidevice and scan the QR code again.',
},
],
[
'Login failed: Entering code or scanning QR timed out. Please try again.',
{
kind: 'login_failed',
reason: 'Entering code or scanning QR timed out. Please try again.',
},
],
[
'Login failed: Unexpected event while waiting for login',
{ kind: 'login_failed', reason: 'Unexpected event while waiting for login' },
],
[
'Login failed: pair error: invalid signature',
{ kind: 'login_failed', reason: 'pair error: invalid signature' },
],
// Pairing-code instructions + the code itself (two separate notices).
[
'Input the pairing code in the WhatsApp mobile app to log in',
{ kind: 'pairing_code_instructions' },
],
// Code body in two valid shapes — plain and markdown-backticked.
['ABCD-1234', { kind: 'pairing_code_displayed', code: 'ABCD-1234' }],
['`WXYZ-9876`', { kind: 'pairing_code_displayed', code: 'WXYZ-9876' }],
// Spaces around the code — RenderMarkdown sometimes preserves a
// leading newline; trim handles it but the regex's `\s*` is belt-
// and-suspenders.
[' PQRS-4567 ', { kind: 'pairing_code_displayed', code: 'PQRS-4567' }],
// Negative case — alphabet excludes I/O/U; an `I` in the slot must
// NOT match. Prevents a stray sentence being misread as a code.
['ABID-1234', { kind: 'unknown' }],
// QR Instructions — swallowed silently as `unknown`.
[
'Scan the QR code with the WhatsApp mobile app to log in',
{ kind: 'unknown' },
],
// External logout — three reasons.
[
'You were logged out from another device. Relogin to continue using the bridge.',
{ kind: 'external_logout', reason: 'another_device' },
],
[
'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
{ kind: 'external_logout', reason: 'phone_logged_out' },
],
[
'You were logged out for an unknown reason. Relogin to continue using the bridge.',
{ kind: 'external_logout', reason: 'unknown' },
],
// Connector-startup notice — same effect as external_logout.
[
"You're not logged into WhatsApp. Relogin to continue using the bridge.",
{ kind: 'external_logout', reason: 'unknown' },
],
// Connection warnings — surfaced in transcript only.
[
'Reconnecting to WhatsApp...',
{ kind: 'connection_warning', text: 'Reconnecting to WhatsApp...' },
],
[
'Disconnected from WhatsApp. Trying to reconnect.',
{
kind: 'connection_warning',
text: 'Disconnected from WhatsApp. Trying to reconnect.',
},
],
[
"Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
{
kind: 'connection_warning',
text: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
},
],
[
'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
{
kind: 'connection_warning',
text: 'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
},
],
[
'Connecting to the WhatsApp web servers failed.',
{
kind: 'connection_warning',
text: 'Connecting to the WhatsApp web servers failed.',
},
],
[
'Stream replaced: the bridge was started in another location.',
{
kind: 'connection_warning',
text: 'Stream replaced: the bridge was started in another location.',
},
],
[
// Bridge-image outdated — `Connect failure: 405 client outdated.
// Bridge must be updated.` from connector handlewhatsapp.go.
// Surfaces as a connection_warning (no state change), the
// eventual login_failed will deliver the actionable error.
'Connect failure: 405 client outdated. Bridge must be updated.',
{
kind: 'connection_warning',
text: 'Connect failure: 405 client outdated. Bridge must be updated.',
},
],
// Relaxed cancel regex — match any leading word + "cancelled." so a
// future bridgev2 introducing additional CommandState.Action values
// (e.g. "Logout cancelled.") still resolves to cancel_ok. Today
// only "Login cancelled." is emitted, but the relaxed match keeps
// us robust to upstream drift.
['Login cancelled.', { kind: 'cancel_ok' }],
['Logout cancelled.', { kind: 'cancel_ok' }],
['Relogin cancelled.', { kind: 'cancel_ok' }],
// Truly unrecognised body — keeps the transcript usable when
// bridgev2 wording drifts.
[
'Some completely unknown bridge reply that does not match any anchor',
{ kind: 'unknown' },
],
// Login list with the leading-newline bug (verified present in
// bridgev2 user.go:185 — same as Telegram dialect).
[
'\n* `12345678901.0` (+12345678901) - `CONNECTED`',
{
kind: 'logins_listed',
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
},
],
// Same row without the bug — keeps matching after upstream fix.
[
'* `12345678901.0` (+12345678901) - `CONNECTED`',
{
kind: 'logins_listed',
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
},
],
];
for (const [body, expected] of cases) {
const actual = parseBridgev2V0264Body(body);
if (!sameEvent(actual, expected)) {
// eslint-disable-next-line no-console
console.error('[bridgev2_v0264 sanity] mismatch', { body, actual, expected });
throw new Error(
`bridgev2_v0264 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
);
}
}
// parseEventBridgev2V0264 — exercises the full-event dispatch (m.image,
// m.room.redaction, m.notice fall-through). Same throw-on-mismatch
// pattern as the body-only parser cases above.
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
[
// Canonical whatsmeow QR — 4 comma-separated base64 fields.
// This shape comes from go.mau.fi/whatsmeow/pair.go::makeQRData.
// The first field (`ref`) typically starts with `2@<base64>`; the
// next three are pure base64.
{
type: 'm.room.message',
event_id: '$qr1',
sender: '@whatsappbot:vojo.chat',
content: {
msgtype: 'm.image',
body: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
},
},
{
kind: 'qr_displayed',
qrData: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
eventId: '$qr1',
},
],
[
// QR rotation edit — `m.relates_to.rel_type=m.replace` + new payload
// inside `m.new_content.body`. The edited payload must take
// precedence over the literal `body`.
{
type: 'm.room.message',
event_id: '$qr2',
sender: '@whatsappbot:vojo.chat',
content: {
msgtype: 'm.image',
body: '2@OldRef,old1,old2,old3',
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
'm.new_content': {
msgtype: 'm.image',
body: '2@NewRef,new1,new2,new3',
},
},
},
{
kind: 'qr_displayed',
qrData: '2@NewRef,new1,new2,new3',
eventId: '$qr2',
replacesEventId: '$qr1',
},
],
[
// Bare m.image without 4-field comma payload — bridge has no business
// sending these to the control DM, but if it does we keep the line
// as `unknown` (transcript surfaces a diag, no QR-state mutation).
// The string has 1 comma → not 4 fields → declined.
{
type: 'm.room.message',
event_id: '$rand',
sender: '@whatsappbot:vojo.chat',
content: { msgtype: 'm.image', body: 'something, unrelated' },
},
{ kind: 'unknown' },
],
[
// 3 fields (one too few) — declined as `unknown`. Defensive against
// a future bridge protocol revision that drops a field; we'd rather
// miss the QR than render a malformed login token into a QR matrix.
{
type: 'm.room.message',
event_id: '$shortqr',
sender: '@whatsappbot:vojo.chat',
content: { msgtype: 'm.image', body: 'a,b,c' },
},
{ kind: 'unknown' },
],
[
// Redaction — top-level `redacts` (host sanitizer mirrors there).
{
type: 'm.room.redaction',
event_id: '$red1',
sender: '@whatsappbot:vojo.chat',
content: { redacts: '$qr1' },
redacts: '$qr1',
},
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
],
[
// Redaction missing target — sanitizer should already reject; defence
// in depth.
{
type: 'm.room.redaction',
event_id: '$red2',
sender: '@whatsappbot:vojo.chat',
content: {},
},
{ kind: 'unknown' },
],
[
// m.notice fall-through — preserves existing body-side parser path.
{
type: 'm.room.message',
event_id: '$n1',
sender: '@whatsappbot:vojo.chat',
content: { msgtype: 'm.notice', body: "You're not logged in" },
},
{ kind: 'not_logged_in' },
],
[
// m.notice carrying the pairing code — full event-level test.
{
type: 'm.room.message',
event_id: '$pc1',
sender: '@whatsappbot:vojo.chat',
content: { msgtype: 'm.notice', body: 'ABCD-1234' },
},
{ kind: 'pairing_code_displayed', code: 'ABCD-1234' },
],
[
// m.notice carrying an external-logout notice — full event level.
{
type: 'm.room.message',
event_id: '$xl1',
sender: '@whatsappbot:vojo.chat',
content: {
msgtype: 'm.notice',
body: 'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
},
},
{ kind: 'external_logout', reason: 'phone_logged_out' },
],
];
for (const [event, expected] of eventCases) {
const actual = parseEventBridgev2V0264(event);
if (!sameEvent(actual, expected)) {
// eslint-disable-next-line no-console
console.error('[bridgev2_v0264 event sanity] mismatch', {
event,
actual,
expected,
});
throw new Error(
`bridgev2_v0264 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
);
}
}
}
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
if (a.kind !== b.kind) return false;
// Shallow JSON-compare the discriminated payload. Good enough for the
// small set of structures we emit; deeper equality would only matter if
// we returned arbitrary nested data.
return JSON.stringify(a) === JSON.stringify(b);
}

View file

@ -1,17 +0,0 @@
// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and
// the dialect handles the full event surface — m.text, m.notice, m.image
// (QR broadcasts), m.room.redaction (post-scan cleanup). v1 ships one
// dialect, `bridgev2_v0264`, for the operator's current bridge image.
// When bridgev2 / mautrix-whatsapp wording drifts in a future Go release,
// add a sibling dialect file and switch the import below.
//
// The dialects/ subdirectory is kept as a seam for that swap; we don't
// implement runtime autodetect (the operator owns one bridge image at a
// time and a parser pin is honest about that).
import type { LoginEvent, ParsableEvent } from './types';
import { parseEventBridgev2V0264 } from './dialects/bridgev2_v0264';
export type { ParsableEvent };
export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventBridgev2V0264(event);

View file

@ -1,122 +0,0 @@
// LoginEvent — discriminated union the parser emits and the state machine
// consumes. One LoginEvent per inbound m.notice / m.text / m.image /
// m.room.redaction from the bridge bot.
//
// Source-of-truth for every kind below is the Go-dialect wording table in
// dialects/bridgev2_v0264.ts (mautrix-whatsapp v0.26.4 + bridgev2 shared
// commands). WhatsApp uses the SAME bridgev2 framework as Telegram, so the
// shared command wordings (`Please enter your X`, `You're not logged in`,
// `Logged out`, list-logins format, cancel replies) are byte-identical to
// the Telegram dialect — only the connector-specific lines differ.
//
// WhatsApp-specific differences vs Telegram dialect:
// - TWO login flows: `qr` and `phone` (pairing-code). `!wa login` alone
// replies `Please specify a login flow…` (flow_required) — the widget
// always sends the full command (`login qr` / `login phone`).
// - QR payload is NOT a URL: it's a raw whatsmeow handshake
// `<ref>,<base64-noise>,<base64-identity>,<base64-adv>` (4 comma-
// separated base64 fields). NEVER appended to transcript verbatim;
// the adv-secret segment IS the login token.
// - QR rotation interval differs from Telegram: first QR lasts 60s,
// then 5 more × 20s each (whatsmeow `qrIntervals`). Total active
// window is 2 min 40 s, vs Telegram's 10 min.
// - NO 2FA cloud-password flow. Multi-device pairing is single-factor;
// the QR scan / pairing-code IS the auth.
// - Login success format: `Successfully logged in as +<phone>` — no
// parens, no numeric ID. Handle is the phone number itself.
// - Pairing-code flow (NEW vs Telegram): bridge replies with two
// m.notice messages — the Instructions string then the code itself
// wrapped in `<code>…</code>` HTML (host driver strips formatted_body,
// leaving the plain `XXXX-XXXX` markdown source in `body`).
// - Async session events from the connector: external logout (phone
// unlinked the device), connection warnings (transient disconnects).
export type ListedLogin = {
id: string;
name: string;
state: string;
};
// Shape of an inbound event the dialect parser needs to look at. Matches
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
// here (not in widget-api.ts) so the dialect doesn't import from the
// transport layer.
export type ParsableEvent = {
type: string;
event_id: string;
sender: string;
origin_server_ts?: number;
content: { msgtype?: string; body?: string; [k: string]: unknown };
redacts?: string;
};
// Reasons why WhatsApp logged us out asynchronously (not via `!wa logout`).
// Carried inside `external_logout` so the UI can pick a wording variant
// that matches the user's understanding ("phone unlinked from settings"
// vs "another linked device kicked us out").
export type ExternalLogoutReason = 'another_device' | 'phone_logged_out' | 'unknown';
export type LoginEvent =
// --- shared bridgev2 command replies (same wording as Telegram) ---------
| { kind: 'logins_listed'; logins: ListedLogin[] }
| { kind: 'not_logged_in' }
| { kind: 'awaiting_phone' }
| { kind: 'login_success'; handle: string }
| { kind: 'logout_ok' }
| { kind: 'cancel_ok' }
| { kind: 'cancel_no_op' }
| { kind: 'login_in_progress' }
| { kind: 'max_logins'; limit?: number }
| { kind: 'login_not_found'; loginId?: string }
| { kind: 'flow_required' }
| { kind: 'flow_invalid'; flowId?: string }
| { kind: 'unknown_command' }
| { kind: 'invalid_value'; reason?: string }
// Generic Go-error trap from bridgev2/commands/login.go's display-and-
// wait branch (`Login failed: <err>`). For mautrix-whatsapp every
// connector-side login error funnels through here:
// - `Phone number too short`
// - `Phone number must be in international format`
// - `Rate limited by WhatsApp`
// - `Got client outdated error while waiting for QRs. The bridge
// must be updated to continue.`
// - `Please enable WhatsApp web multidevice and scan the QR code
// again.`
// - `Entering code or scanning QR timed out. Please try again.`
// - `Unexpected event while waiting for login`
// - `Pair error: <err>` (specific PairError surfacing)
// The widget keeps the verbatim reason string and does NOT sub-classify
// — the upstream wording is structured enough that the user can read it.
| { kind: 'login_failed'; reason?: string }
| { kind: 'submit_failed'; reason?: string }
| { kind: 'prepare_failed'; reason?: string }
| { kind: 'start_failed'; reason?: string }
// --- QR-flow lifecycle (m.image broadcasts, m.room.redaction cleanup) ---
// `qrData` is the raw whatsmeow payload — keep it OUT of any DOM-level
// log. The state machine renders it into a QR matrix client-side; once
// rendered the matrix is harmless (a screenshot of it would be stale by
// the next rotation), but the raw string itself should never be append-
// ed to the transcript.
| { kind: 'qr_displayed'; qrData: string; eventId: string; replacesEventId?: string }
| { kind: 'qr_redacted'; redactsEventId: string }
// --- Pairing-code flow (WhatsApp-specific) ------------------------------
// First of two notices after a phone-number submit on `!wa login phone`:
// `Input the pairing code in the WhatsApp mobile app to log in`. The
// state machine flips into a "pairing code is coming" interstitial on
// this event so the user sees an immediate change after submit.
| { kind: 'pairing_code_instructions' }
// Second of the two notices: the actual `XXXX-XXXX` code. The state
// machine flips to `pairing_code_shown{code}` and the UI renders the
// code prominently with copy-friendly letter-spacing.
| { kind: 'pairing_code_displayed'; code: string }
// --- Async post-login session events (connector-emitted m.notice) -------
// External logout — the bridge lost its session because the phone or
// another linked device unlinked us. Routes the live state to
// disconnected with a `lastError` flag so the UI surfaces a banner.
| { kind: 'external_logout'; reason: ExternalLogoutReason }
// Soft connection warnings — `Reconnecting to WhatsApp...`, `Disconnected
// from WhatsApp. Trying to reconnect.`, `Your phone hasn't been seen…`.
// The widget surfaces these in the transcript only; state isn't
// touched (the bridge is still operational, just having a hiccup).
| { kind: 'connection_warning'; text: string }
| { kind: 'unknown' };

View file

@ -0,0 +1,408 @@
// Contacts surface: the user's WhatsApp address book (GET /v3/contacts) with
// a single search field that doubles as «start a chat with anyone»:
//
// * typing letters filters the local list;
// * typing a +phone shape surfaces an explicit «check on WhatsApp» action
// (GET /v3/resolve_identifier — never fired per keystroke: the connector
// answers it with a live IsOnWhatsApp server query, so probing is
// click-gated). Phones are the ONLY identifier WhatsApp resolves — no
// usernames (connector validateIdentifer);
// * every row's action either opens the existing DM portal
// (`dm_room_mxid` from the API) or creates one (POST /v3/create_dm)
// and asks the host to navigate there.
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import {
ProvisioningClient,
contactHandles,
detectIdentifier,
type Contact,
type ProbeIdentifier,
} from './provisioning';
import { describeApiError } from './errors';
import { useMxcAvatar } from './avatars';
import type { WidgetApi } from './widget-api';
import { Avatar, RefreshIcon, SearchIcon, Spinner } from './ui';
import type { T } from './i18n';
type ListState =
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'ready'; contacts: Contact[] };
type ProbeState =
| { status: 'checking'; identifier: ProbeIdentifier }
| { status: 'found'; identifier: ProbeIdentifier; contact: Contact }
| { status: 'not-found'; identifier: ProbeIdentifier };
type ContactsProps = {
client: ProvisioningClient;
/** Widget transport — used for MSC4039 avatar downloads. */
api: WidgetApi;
t: T;
/** WhatsApp login id of the linked account (whoami login id bare phone
* digits). Your own address-book entry is hidden from the list, and
* probing your own number answers «это вы» instead of offering a chat. */
selfId?: string;
/** Reports the avatar mxc of YOUR OWN address-book entry once the list
* loads the most reliable own-avatar source (whoami's profile.avatar is
* empty until the bridge meets your ghost). */
onSelfAvatar?: (mxc: string) => void;
/** Bump to re-fetch the list the refresh control lives in the parent's
* header row, next to the status pill. */
reloadToken?: number;
/** List-fetch in-flight signal for the parent's refresh button. */
onLoadingChange?: (loading: boolean) => void;
/** Ask the host to navigate to a room (matrix.to side-channel verb). */
onOpenRoom: (roomId: string) => void;
/** Surface a terminal action error in the global notice strip. */
onError: (message: string) => void;
};
// --- Contact row -------------------------------------------------------------
type ContactRowProps = {
contact: Contact;
api: WidgetApi;
t: T;
busy: boolean;
/** Another row's action is in flight — this row's button is disabled. */
anotherBusy: boolean;
onAction: () => void;
};
const ContactRow = ({ contact, api, t, busy, anotherBusy, onAction }: ContactRowProps) => {
const { phone } = contactHandles(contact);
const phoneText = phone ? `+${phone.replace(/^\+/, '')}` : null;
const linked = Boolean(contact.dm_room_mxid);
const avatarSrc = useMxcAvatar(api, contact.avatar_url);
return (
<div class="contact-row">
<Avatar name={contact.name} colorKey={contact.id} src={avatarSrc} />
<div class="contact-main">
<div class="contact-name">
<span class="contact-name-text">{contact.name || phoneText || contact.id}</span>
</div>
{phoneText && contact.name ? (
<div class="contact-handle">
<span class="contact-handle-part">{phoneText}</span>
</div>
) : null}
</div>
<button
type="button"
class={`contact-action${linked ? ' linked' : ''}`}
disabled={anotherBusy || busy}
onClick={onAction}
>
{busy ? (
<>
<Spinner />
{linked ? t('contacts.opening') : t('contacts.creating')}
</>
) : (
<>{linked ? t('contacts.open-chat') : t('contacts.start-chat')}</>
)}
</button>
</div>
);
};
const contactSearchHaystack = (contact: Contact): string => {
const { phone } = contactHandles(contact);
return [contact.name ?? '', phone ?? ''].join(' ').toLowerCase();
};
const sortContacts = (contacts: Contact[]): Contact[] =>
[...contacts].sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' })
);
export const Contacts = ({
client,
api,
t,
selfId,
onSelfAvatar,
reloadToken,
onLoadingChange,
onOpenRoom,
onError,
}: ContactsProps) => {
const [list, setList] = useState<ListState>({ kind: 'loading' });
const [query, setQuery] = useState('');
const [probe, setProbe] = useState<ProbeState | null>(null);
// One in-flight row action at a time; the value is the contact id (or the
// probe identifier) whose button is busy.
const [busyId, setBusyId] = useState<string | null>(null);
const aliveRef = useRef(true);
useEffect(
() => () => {
aliveRef.current = false;
},
[]
);
// Ref-shims so the parent's per-render callback identities don't churn
// `load` (which would re-fetch the contact list on every parent render).
const onSelfAvatarRef = useRef(onSelfAvatar);
onSelfAvatarRef.current = onSelfAvatar;
const onLoadingChangeRef = useRef(onLoadingChange);
onLoadingChangeRef.current = onLoadingChange;
const load = useCallback(() => {
setList({ kind: 'loading' });
onLoadingChangeRef.current?.(true);
client
.listContacts()
.then((contacts) => {
onLoadingChangeRef.current?.(false);
if (!aliveRef.current) return;
// Your own address-book entry is hidden from the rows below, but its
// avatar is the best own-avatar source — hand it up before filtering.
const selfEntry = selfId ? contacts.find((c) => c.id === selfId) : undefined;
if (selfEntry?.avatar_url) onSelfAvatarRef.current?.(selfEntry.avatar_url);
setList({ kind: 'ready', contacts: sortContacts(contacts) });
})
.catch((err) => {
onLoadingChangeRef.current?.(false);
if (!aliveRef.current) return;
setList({ kind: 'error', message: describeApiError(err, t) });
});
}, [client, t, selfId]);
useEffect(() => {
load();
// reloadToken is the parent header's refresh button — same load, new tick.
}, [load, reloadToken]);
// The WhatsApp contact store can include your own entry — hide it; «начать
// чат с собой» reads as a glitch here (message-yourself is not this
// surface).
const visibleContacts = useMemo(() => {
if (list.kind !== 'ready') return [];
return selfId ? list.contacts.filter((c) => c.id !== selfId) : list.contacts;
}, [list, selfId]);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return visibleContacts;
// `+7 999…` queries should also match the local list.
const bare = needle.replace(/^\+/, '').replace(/[\s\-()]/g, '');
return visibleContacts.filter((c) => {
const haystack = contactSearchHaystack(c);
return haystack.includes(needle) || (bare.length > 0 && haystack.includes(bare));
});
}, [visibleContacts, query]);
const probeCandidate = useMemo(() => detectIdentifier(query), [query]);
// When the typed number exactly matches someone already in the visible
// list, the list row IS the answer — offering a parallel «проверить в
// WhatsApp» path would just duplicate the same person with two buttons.
// Self is intentionally NOT considered a match here, so probing your own
// number still reaches the «это вы» reply instead of dead-ending.
const probeMatchesLocal = useMemo(() => {
if (!probeCandidate) return false;
const digits = probeCandidate.value.replace(/\D/g, '');
return visibleContacts.some(
(c) => (contactHandles(c).phone ?? '').replace(/\D/g, '') === digits
);
}, [probeCandidate, visibleContacts]);
// The probe row hides once its result is stale (query changed).
const activeProbe =
probe && probeCandidate && probe.identifier.value === probeCandidate.value ? probe : null;
// Latest-wins guard: probes aren't serialized by the UI (editing the query
// re-enables the button while an older probe is still in flight), so a
// slow stale response must not stomp a fresher result or fire a
// misattributed error notice.
const probeSeq = useRef(0);
const runProbe = useCallback(() => {
if (!probeCandidate) return;
probeSeq.current += 1;
const seq = probeSeq.current;
setProbe({ status: 'checking', identifier: probeCandidate });
client
.resolveIdentifier(probeCandidate.value)
.then((contact) => {
if (!aliveRef.current || seq !== probeSeq.current) return;
setProbe(
contact
? { status: 'found', identifier: probeCandidate, contact }
: { status: 'not-found', identifier: probeCandidate }
);
})
.catch((err) => {
if (!aliveRef.current || seq !== probeSeq.current) return;
setProbe(null);
onError(describeApiError(err, t));
});
}, [client, probeCandidate, onError, t]);
// After we hand a room to the host, the widget normally unmounts when the
// host navigates (≤15 s: BotWidgetMount waits for the fresh portal to
// sync+join first). If navigation never happens — host-side parse failure,
// room never syncing — re-enable the button instead of spinning forever.
const busyResetTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current);
},
[]
);
const handOffToHost = useCallback(
(roomId: string) => {
onOpenRoom(roomId);
if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current);
busyResetTimer.current = window.setTimeout(() => {
if (aliveRef.current) setBusyId(null);
}, 20_000);
},
[onOpenRoom]
);
const startChat = useCallback(
(contact: Contact, busyKey: string) => {
if (busyId !== null) return;
setBusyId(busyKey);
if (contact.dm_room_mxid) {
// Portal already exists — pure navigation, the host takes it from
// here (the widget unmounts on route change).
handOffToHost(contact.dm_room_mxid);
return;
}
client
.createDm(contact.id)
.then((resolved) => {
if (!aliveRef.current) return;
if (resolved.dm_room_mxid) {
handOffToHost(resolved.dm_room_mxid);
} else {
setBusyId(null);
onError(t('error.generic', { reason: 'no room id' }));
}
})
.catch((err) => {
if (!aliveRef.current) return;
setBusyId(null);
onError(describeApiError(err, t));
});
},
[busyId, client, handOffToHost, onError, t]
);
const renderRow = (contact: Contact, busyKey: string) => (
<ContactRow
key={busyKey}
contact={contact}
api={api}
t={t}
busy={busyId === busyKey}
anotherBusy={busyId !== null && busyId !== busyKey}
onAction={() => startChat(contact, busyKey)}
/>
);
const renderProbeArea = () => {
if (!probeCandidate || probeMatchesLocal) return null;
if (activeProbe?.status === 'found') {
// Resolving your own number is technically valid (message-yourself),
// but reads as a glitch in a contact picker — surface it as «это вы»
// instead of an actionable row.
if (selfId && activeProbe.contact.id === selfId) {
return (
<div class="probe-result">
<div class="probe-result-label missing">{t('contacts.probe-self')}</div>
</div>
);
}
return (
<div class="probe-result">
<div class="probe-result-label">{t('contacts.probe-found')}</div>
{renderRow(activeProbe.contact, `probe:${activeProbe.contact.id}`)}
</div>
);
}
if (activeProbe?.status === 'not-found') {
return (
<div class="probe-result">
<div class="probe-result-label missing">
{t('contacts.probe-not-found', { handle: activeProbe.identifier.display })}
</div>
</div>
);
}
const checking = activeProbe?.status === 'checking';
return (
<button type="button" class="probe-check" onClick={runProbe} disabled={checking}>
{checking ? <Spinner /> : <SearchIcon />}
{checking
? t('contacts.probe-checking', { handle: probeCandidate.display })
: t('contacts.probe-check', { handle: probeCandidate.display })}
</button>
);
};
return (
<div class="contacts">
<form
class="search-shell"
onSubmit={(e) => {
e.preventDefault();
// Enter in the search field fires the probe when the input looks
// like a phone number — the keyboard path to «написать по номеру».
if (probeCandidate && !probeMatchesLocal && activeProbe?.status !== 'checking') {
runProbe();
}
}}
>
<span class="search-shell-icon" aria-hidden="true">
<SearchIcon />
</span>
<input
class="search-input"
type="text"
placeholder={t('contacts.search-placeholder')}
value={query}
onInput={(e) => setQuery((e.currentTarget as HTMLInputElement).value)}
/>
</form>
{!query && list.kind === 'ready' ? <div class="hint">{t('contacts.hint')}</div> : null}
{renderProbeArea()}
{list.kind === 'loading' ? (
<div class="contacts-placeholder" role="status">
<Spinner />
{t('contacts.loading')}
</div>
) : null}
{list.kind === 'error' ? (
<div class="contacts-placeholder">
<span class="contacts-error-text">{list.message || t('contacts.error')}</span>
<button type="button" class="recovery-action" onClick={load}>
<RefreshIcon />
{t('contacts.retry')}
</button>
</div>
) : null}
{list.kind === 'ready' ? (
<>
{filtered.length > 0 ? (
<div class="contact-list">{filtered.map((c) => renderRow(c, c.id))}</div>
) : (
<div class="contacts-placeholder">
{query.trim() ? t('contacts.empty-filtered') : t('contacts.empty')}
</div>
)}
</>
) : null}
</div>
);
};

View file

@ -0,0 +1,86 @@
// Map transport/bridge errors to localized human copy. The connector ships
// stable errcodes for every login failure (pkg/connector/login.go:46-82 —
// FI.MAU.WHATSAPP.*), so unlike the Telegram widget there is no need for
// substring matching against upstream error text; errcodes are authoritative.
import { ProvisioningError } from './provisioning';
import type { T } from './i18n';
export const describeApiError = (err: unknown, t: T): string => {
if (err instanceof ProvisioningError) {
switch (err.errcode) {
// Bad phone input (whatsmeow validation, login.go:200-203). The
// connector kills the login process on these — the form transparently
// restarts on resubmit.
case 'FI.MAU.WHATSAPP.PHONE_NUMBER_TOO_SHORT':
case 'FI.MAU.WHATSAPP.PHONE_NUMBER_NOT_INTERNATIONAL':
return t('error.phone-invalid');
// whatsmeow ErrIQRateOverLimit while requesting a pairing code
// (login.go:204-205) — WhatsApp throttles repeat requests hard.
case 'FI.MAU.WHATSAPP.RATE_LIMITED':
return t('error.rate-limited');
// QR codes ran out (~160 s window) or the pairing socket dropped
// (login.go:275-277).
case 'FI.MAU.WHATSAPP.LOGIN_TIMEOUT':
return t('error.login-timeout');
// QR scanned from a WhatsApp account without multidevice
// (login.go:259-261).
case 'FI.MAU.WHATSAPP.MULTIDEVICE_NOT_ENABLED':
return t('error.multidevice');
// whatsmeow is too old for WhatsApp's servers — operator-side problem
// (login.go:262-264).
case 'FI.MAU.WHATSAPP.CLIENT_OUTDATED':
return t('error.client-outdated');
// Phone-side pair rejection / unexpected socket event
// (login.go:268-280).
case 'FI.MAU.WHATSAPP.PAIR_ERROR':
case 'FI.MAU.WHATSAPP.LOGIN_UNEXPECTED_EVENT':
return t('error.pair-failed');
case 'FI.MAU.BRIDGE.TOO_MANY_LOGINS':
return t('error.too-many-logins');
// Framework-level login lifecycle errors (mautrix ≥ v0.28.1
// provisioninglogin.go — the server-side 30-minute login TTL).
case 'FI.MAU.BRIDGE.LOGIN_TIMED_OUT':
return t('error.login-timeout');
case 'FI.MAU.BRIDGE.LOGIN_CANCELLED':
return t('error.login-restart');
// The login step machine advanced without us (a re-poll raced a state
// change) — the process is desynced beyond recovery; start over.
case 'M_BAD_STATE':
return t('error.login-restart');
// Identifier probe rejected as non-phone (startchat.go:52 — WhatsApp
// only resolves phone numbers).
case 'M_INVALID_PARAM':
return t('error.phone-invalid');
case 'M_MISSING_TOKEN':
case 'M_UNKNOWN_TOKEN':
return t('error.auth');
case 'M_FORBIDDEN':
// The connector reuses M_FORBIDDEN for «must be logged in to list
// contacts» (startchat.go:184) — e.g. the session was severed from
// the phone while the contacts view is open. That's a relink
// problem, not an OpenID one.
return err.message.toLowerCase().includes('logged in')
? t('error.not-logged-in')
: t('error.auth');
default:
break;
}
// Login process evaporated server-side (the bridge deletes it on any
// step error and when the QR window closes) — start over.
if (err.httpStatus === 404) return t('error.login-restart');
return t('error.generic', { reason: err.message });
}
// Host refused to issue OpenID creds — an operator-side config gap
// (missing `vojo.openid` capability), not a transient failure; say so.
if (err instanceof Error && err.message.includes('blocked by host')) {
return t('error.openid-blocked');
}
if (err instanceof Error && err.message.includes('OpenID')) return t('error.auth');
// fetch() network failures surface as TypeError; transport timeouts abort
// with AbortError (DOMException, also instanceof Error with name).
if (err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError')) {
return t('error.network');
}
return t('error.generic', { reason: err instanceof Error ? err.message : String(err) });
};

View file

@ -1,32 +1,36 @@
// English fallback. Mirror the RU key set; `Record<StringKey, string>` // English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
// enforces every RU key has an EN counterpart at compile time. // that every RU key has an EN counterpart at compile time.
import type { StringKey } from './ru'; import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = { export const EN: Record<StringKey, string> = {
'status.unknown': 'Checking status…', // --- Status pill ---------------------------------------------------------
'status.disconnected': 'WhatsApp not linked', 'status.checking': 'Checking status…',
'status.connected': 'WhatsApp linked', 'status.disconnected': 'WhatsApp is not linked',
'status.connected-as': 'WhatsApp linked as {handle}', 'status.connected-as': 'Linked as {handle}',
'status.logging-out': 'Signing out…',
'status.qr-verifying': 'Verifying sign-in…', // --- Action cards ----------------------------------------------------------
'status.pairing-verifying': 'Verifying sign-in…', 'card.login.name': 'Sign in by phone number',
'card.login.desc': 'Enter your number and get an 8-character code for WhatsApp',
'card.login-qr.name': 'Sign in with QR code', 'card.login-qr.name': 'Sign in with QR code',
'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app', 'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app',
'card.login-pairing.name': 'Sign in by phone number',
'card.login-pairing.desc': 'Enter your number and get an 8-character code for WhatsApp',
'card.refresh.aria': 'Refresh status',
'card.refresh.label': 'Refresh status',
'card.refresh.name': 'Refresh status', 'card.refresh.name': 'Refresh status',
'card.refresh.desc': 'Re-check whether WhatsApp is linked', 'card.refresh.desc': 'Re-check whether WhatsApp is linked',
'card.refresh.in-flight': 'Checking…', 'card.refresh.in-flight': 'Checking…',
'card.logout.name': 'Sign out of WhatsApp',
'card.logout.desc': 'End the session on this account',
'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel',
// --- About panel -----------------------------------------------------------
'card.about.name': 'How the WhatsApp bot works',
'card.about.desc': 'How it works and the risks — tap to read',
'warning.title': 'Important before linking WhatsApp', 'warning.title': 'Important before linking WhatsApp',
'warning.body-1': 'warning.body-1':
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApps terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.', 'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApps terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
'warning.tos-label': 'WhatsApp terms of service:', 'warning.tos-label': 'WhatsApp terms of service:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'card.about.name': 'How the WhatsApp bot works',
'card.about.desc': 'How it works and the risks — tap to read',
'about.title': 'About the WhatsApp bot', 'about.title': 'About the WhatsApp bot',
'about.body-1': 'about.body-1':
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.', 'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',
@ -37,73 +41,110 @@ export const EN: Record<StringKey, string> = {
'about.github-label': 'The bridge source code is public on GitHub:', 'about.github-label': 'The bridge source code is public on GitHub:',
'about.github-url': 'https://github.com/mautrix/whatsapp', 'about.github-url': 'https://github.com/mautrix/whatsapp',
'about.body-4': 'about.body-4':
'You can revoke access at any time — either with the “Sign out of WhatsApp” button here, or inside WhatsApp itself under Settings → Linked devices → Log out of all devices.', 'You can revoke access at any time — with the “Sign out of WhatsApp” button here, or in WhatsApp itself under Settings → Linked devices → Log out from all devices.',
'about.close': 'Close', 'about.close': 'Close',
'about.aria-close': 'Close “About this bot”', 'about.aria-close': 'Close “About the bot”',
'auth-card.phone.title': 'Sign in with a pairing code',
// --- Phone form (pairing-code flow) -----------------------------------------
'auth-card.phone.title': 'Sign in by phone number',
'auth-card.phone.label': 'Phone number', 'auth-card.phone.label': 'Phone number',
'auth-card.phone.placeholder': '+15551234567', 'auth-card.phone.placeholder': '+79991234567',
'auth-card.phone.hint': 'auth-card.phone.hint':
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.', 'Enter your number with the country code. WhatsApp will then create an 8-character code to enter in the app.',
'auth-card.phone.submit': 'Get code', 'auth-card.phone.submit': 'Get the code',
'auth-card.phone.cooldown': 'Retry in {seconds}s', 'auth-card.phone.cooldown': 'Retry in {seconds} s',
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.", 'auth-card.phone.invalid': 'The number looks incomplete or mistyped.',
// --- Pairing-code panel --------------------------------------------------
'auth-card.pairing-code.title': 'Enter this code in WhatsApp', 'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
'auth-card.pairing-code.hint': 'auth-card.pairing-code.hint':
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.', 'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
'auth-card.pairing-code.preparing': 'Preparing the code…', 'auth-card.pairing-code.aria': 'WhatsApp sign-in code. Enter it in the app on your phone.',
'auth-card.pairing-code.aria': 'auth-card.pairing-code.countdown': '{minutes}:{seconds} left to enter the code',
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.', 'auth-card.pairing-code.expired': 'The sign-in window expired. Press “Cancel” and try again.',
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.', 'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.', 'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.',
'auth-card.pairing-code.step-3': 'Tap Link a device → Link with phone number.', 'auth-card.pairing-code.step-3': 'Tap “Link a device → Link with phone number”.',
'auth-card.pairing-code.step-4': 'Enter this code and confirm sign-in on your phone.', 'auth-card.pairing-code.step-4': 'Enter this code and confirm the sign-in on your phone.',
'auth-card.qr.title': 'QR code sign-in', 'auth-card.pairing-code.copy': 'Copy the code',
'auth-card.pairing-code.copied': 'Copied',
// --- Shared form chrome ------------------------------------------------
'auth-card.cancel': 'Cancel',
'auth-card.waiting-hint': 'The bridge is still thinking… replies can take up to 30 seconds.',
// --- QR panel ------------------------------------------------------------
'auth-card.qr.title': 'Sign in with QR code',
'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.', 'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.',
'auth-card.qr.preparing': 'Preparing QR code…', 'auth-card.qr.preparing': 'Preparing the QR code…',
'auth-card.qr.aria': 'QR code for WhatsApp sign-in. Scan it with your phone.', 'auth-card.qr.aria': 'QR code for signing in to WhatsApp. Scan it with your phone.',
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}', 'auth-card.qr.countdown': '{minutes}:{seconds} left to scan',
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.', 'auth-card.qr.expired': 'The sign-in window expired. Press “Cancel” and try again.',
'auth-card.qr.step-1': 'Open WhatsApp on your phone.', 'auth-card.qr.step-1': 'Open WhatsApp on your phone.',
'auth-card.qr.step-2': 'Go to Settings → Linked devices.', 'auth-card.qr.step-2': 'Go to Settings → Linked devices.',
'auth-card.qr.step-3': 'Tap Link a device and scan the QR code.', 'auth-card.qr.step-3': 'Tap “Link a device” and scan this QR code.',
'auth-card.cancel': 'Cancel',
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.', // --- Global errors / notices ---------------------------------------------
'auth-error.login-failed': 'Sign-in failed: {reason}', 'error.network': 'No connection to the server. Check your internet and try again.',
'auth-error.invalid-value': 'Value not accepted: {reason}', 'error.auth':
'auth-error.submit-failed': 'WhatsApp refused the input: {reason}', 'Could not verify your account with the bridge. Reload the page; if that does not help, try again later.',
'auth-error.start-failed': 'Failed to start sign-in: {reason}', 'error.not-logged-in':
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', 'The WhatsApp session is no longer active. Go back and link the account again.',
'auth-error.login-in-progress': 'error.openid-blocked':
'The bot already has another sign-in flow open. Click Cancel and retry.', 'The host did not grant the widget sign-in permission — the bot in config.json is missing the vojo.openid capability.',
'auth-error.max-logins': 'Login limit reached ({limit}). Sign out of an existing account first.', 'error.rate-limited': 'WhatsApp asks to wait: too many code requests. Try again later.',
'auth-error.unknown-command': 'error.phone-invalid':
'The bot does not recognise this command — check the prefix in config.json.', 'WhatsApp rejected this number. Enter it in international format and try again.',
'auth-error.external-logout.another-device': 'error.multidevice':
'WhatsApp unlinked this device from another device. Sign in again.', 'WhatsApp multi-device is not enabled on your phone. Update the WhatsApp app and try again.',
'auth-error.external-logout.phone-logged-out': 'error.client-outdated':
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.', 'The bridge is outdated and WhatsApp refuses its connection. Tell the Vojo administrator.',
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.', 'error.pair-failed': 'WhatsApp did not confirm the pairing. Start the sign-in over.',
'card.logout.name': 'Sign out of WhatsApp', 'error.login-timeout': 'The sign-in window expired. Start over.',
'card.logout.desc': 'End the session for this account', 'error.login-restart': 'The sign-in session was lost. Start over.',
'card.logout.confirm-prompt': 'Sign out for real?', 'error.too-many-logins': 'Linked-account limit reached. Sign out of the current one first.',
'card.logout.confirm-yes': 'Sign out', 'error.generic': 'Something went wrong: {reason}',
'card.logout.confirm-no': 'Cancel', 'notice.login-success': 'WhatsApp is linked! Chats will appear in the list within a minute.',
'card.logout.gated': 'Session identifier still loading — give it a moment.', 'notice.logged-out': 'WhatsApp session ended.',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.', // --- Contacts ------------------------------------------------------------
'diag.checking-status': 'Checking connection status…', 'card.contacts.name': 'Contacts',
'diag.send-failed': 'send failed: {message}', 'card.contacts.desc': 'Your WhatsApp address book: search by name or number',
'diag.history-marker': '─── history ───', 'contacts.back': 'Back',
'diag.history-unavailable': 'Could not read history — re-checking status.', 'contacts.search-placeholder': 'Name or +number…',
'diag.qr-issued': 'QR code refreshed.', 'contacts.hint':
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.', 'These are the contacts from your WhatsApp address book. Pick who to start a chat with in Vojo — the rest are not going anywhere.',
'diag.pairing-code-issued': 'Pairing code issued.', 'contacts.loading': 'Loading contacts…',
'diag.connection-warning': '{text}', 'contacts.error': 'Could not load contacts.',
'diag.external-logout': 'WhatsApp dropped the session — sign-in needed.', 'contacts.retry': 'Retry',
'bootstrap.failed': 'Widget failed to start', 'contacts.empty': 'Your WhatsApp address book is empty so far.',
'bootstrap.missing-params': 'Missing required URL params: {names}.', 'contacts.empty-filtered': 'Nobody found with that name.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.', 'contacts.start-chat': 'Start chat',
'contacts.open-chat': 'Open chat',
'contacts.creating': 'Creating chat…',
'contacts.opening': 'Opening…',
'contacts.probe-check': 'Check {handle} on WhatsApp',
'contacts.probe-checking': 'Checking {handle}…',
'contacts.probe-not-found': '{handle} was not found on WhatsApp.',
'contacts.probe-found': 'Found them! You can start a chat.',
'contacts.probe-self': 'That is your own number.',
'contacts.refresh': 'Refresh list',
// --- Account tab -----------------------------------------------------------
'account.state-bad':
'The bridge reports a connection problem: {reason}. Try signing out and linking WhatsApp again.',
'account.state-transient':
'The bridge temporarily lost the connection to WhatsApp. Check that your phone is online — it usually recovers on its own.',
// --- Boot / config ---------------------------------------------------------
'boot.connecting': 'Connecting to the bridge…',
'config.missing.title': 'Server-side setup required',
'config.missing.body':
'The widget talks to the bridge API, but its address is not configured (experience.provisioningUrl in config.json) or the vojo.openid permission is missing.',
'error.retry': 'Retry',
// --- Bootstrap failure -------------------------------------------------
'bootstrap.failed': 'The widget failed to start',
'bootstrap.missing-params': 'Required URL parameters are missing: {names}.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at the {route} route.',
}; };

View file

@ -1,7 +1,7 @@
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix // Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
// match — any `en` variant). Bootstrap forwards `clientLanguage` from // any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
// the host; main.tsx can also call `createT()` without args before // can also call `createT()` without args before bootstrap completes (falls back
// bootstrap completes (falls back to navigator.language, then RU). // to navigator.language, then RU).
import { RU, type StringKey } from './ru'; import { RU, type StringKey } from './ru';
import { EN } from './en'; import { EN } from './en';

View file

@ -4,77 +4,61 @@
// 2. add the same key + EN value in `en.ts`, // 2. add the same key + EN value in `en.ts`,
// 3. consume via `t('key', { var: 'x' })` in components. // 3. consume via `t('key', { var: 'x' })` in components.
// Interpolation uses `{name}` placeholders resolved against the second arg. // Interpolation uses `{name}` placeholders resolved against the second arg.
//
// The widget no longer renders a hero — that block lives in the host's
// BotShellHero. Status is surfaced inline inside the relevant section.
export const RU = { export const RU = {
// --- Inline section status --------------------------------------------- // --- Status pill ---------------------------------------------------------
'status.unknown': 'Проверка статуса…', 'status.checking': 'Проверка статуса…',
'status.disconnected': 'WhatsApp не привязан', 'status.disconnected': 'WhatsApp не привязан',
'status.connected': 'WhatsApp привязан', 'status.connected-as': 'Привязан как {handle}',
'status.connected-as': 'WhatsApp привязан как {handle}',
'status.logging-out': 'Завершение сеанса…', // --- Action cards ----------------------------------------------------------
// QR-вход: после успешного скана мост стирает QR и переходит к // User flow по сути такой же, как в Telegram: сабмит номера → код. Отличие:
// подтверждению линка. Это короткий промежуточный pill. // в TG код вводится в виджет, в WA — в само приложение WhatsApp. Имя кнопки
'status.qr-verifying': 'Проверяем вход…', // одинаковое для consistency между виджетами.
// Pairing-code вход: после ввода кода в приложении ждём, пока WhatsApp 'card.login.name': 'Войти по номеру',
// подтвердит линк. По времени совпадает с qr-verifying — секунды. 'card.login.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
'status.pairing-verifying': 'Проверяем вход…',
// --- Section headers ---------------------------------------------------
'card.login-qr.name': 'Войти по QR-коду', 'card.login-qr.name': 'Войти по QR-коду',
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp', 'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp',
// WA-эквивалент TG-шного «Войти по номеру». User flow по сути такой
// же, как в Telegram: сабмит номера → бот выдаёт код → код вводится.
// Отличие: в TG код вводится в виджет, в WA — в само приложение
// WhatsApp. Имя кнопки одинаковое для consistency между виджетами.
'card.login-pairing.name': 'Войти по номеру',
'card.login-pairing.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
'card.refresh.aria': 'Обновить статус',
'card.refresh.label': 'Обновить статус',
'card.refresh.name': 'Обновить статус', 'card.refresh.name': 'Обновить статус',
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp', 'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
'card.refresh.in-flight': 'Проверяю…', 'card.refresh.in-flight': 'Проверяю…',
// --- About panel ------------------------------------------------------- 'card.logout.name': 'Выйти из WhatsApp',
// WhatsApp-only Meta-ToS risk disclosure is folded into the About 'card.logout.desc': 'Завершить сеанс на этом аккаунте',
// modal as an amber callout at the top of the body. The AboutCard 'card.logout.confirm-prompt': 'Точно выйти?',
// itself carries `command-card warn` (amber border + amber name) 'card.logout.confirm-yes': 'Выйти',
// and a triangle warning glyph in the lead slot — instead of the 'card.logout.confirm-no': 'Отмена',
// info-circle TG / Discord use — so the «риски» half of the hybrid
// description («о работе и рисках») is visible at a glance before // --- About panel -----------------------------------------------------------
// the user opens the modal. TG / Discord get the plain «вход, // WhatsApp-only Meta-ToS risk disclosure is folded into the About modal as
// безопасность, исходный код» variant because they don't carry an // an amber callout at the top of the body. The About card itself carries
// account-loss risk in the same way (Telegram user ToS doesn't // `command-card warn` (amber border + amber name) and a triangle warning
// forbid third-party clients; Discord's restriction on self-bots // glyph in the lead slot — instead of the info-circle TG / Discord use —
// lives in developer policies, not user ToS proper). The amber // so the «риски» half of the hybrid description («о работе и рисках») is
// block keeps the unique WhatsApp framing without claiming anything // visible at a glance before the user opens the modal. TG / Discord get
// about TG / Discord by comparison. // the plain «вход, безопасность, исходный код» variant because they don't
// carry an account-loss risk in the same way (Telegram user ToS doesn't
// forbid third-party clients; Discord's restriction on self-bots lives in
// developer policies, not user ToS proper).
// //
// ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service // ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
// section «Harm To WhatsApp Or Our Users» forbids «software or // section «Harm To WhatsApp Or Our Users» forbids «software or APIs that
// APIs that function substantially the same as our Services» and // function substantially the same as our Services» and «accounts for our
// «accounts for our Services through unauthorized or automated // Services through unauthorized or automated means».
// means». 'card.about.name': 'Как работает WhatsApp-бот',
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
'warning.title': 'Важно знать до подключения WhatsApp', 'warning.title': 'Важно знать до подключения WhatsApp',
'warning.body-1': 'warning.body-1':
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.', 'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
// Источник про запрет в ToS — даём юзеру возможность дойти до // Источник про запрет в ToS — даём юзеру возможность дойти до оригинала
// оригинала самому, не доверять нам на слово. Кликается потому что // самому, не доверять нам на слово. Кликается потому что host-side iframe
// host-side iframe sandbox получил allow-popups (см. // sandbox получил allow-popups (см. src/app/features/bots/BotWidgetEmbed.ts).
// src/app/features/bots/BotWidgetEmbed.ts).
'warning.tos-label': 'Условия использования WhatsApp:', 'warning.tos-label': 'Условия использования WhatsApp:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'card.about.name': 'Как работает WhatsApp-бот',
// Hybrid copy: tells the user the modal carries BOTH the «как
// работает» explainer AND the Meta-ToS risk disclosure. «нажмите,
// чтобы прочесть» reinforces interactivity — the amber border +
// warning triangle help but the explicit verb seals it.
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
'about.title': 'О боте WhatsApp', 'about.title': 'О боте WhatsApp',
'about.body-1': 'about.body-1':
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.', 'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',
'about.body-2': 'about.body-2':
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».', 'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязка устройства», либо ввести 8-символьный код через «Настройки → Связанные устройства → Связать по номеру телефона».',
'about.body-3': 'about.body-3':
'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.', 'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.',
'about.github-label': 'Исходный код моста открыт на GitHub:', 'about.github-label': 'Исходный код моста открыт на GitHub:',
@ -83,8 +67,9 @@ export const RU = {
'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».', 'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».',
'about.close': 'Закрыть', 'about.close': 'Закрыть',
'about.aria-close': 'Закрыть «О боте»', 'about.aria-close': 'Закрыть «О боте»',
// --- Phone form (pairing-code flow) ------------------------------------
'auth-card.phone.title': 'Вход по коду из приложения', // --- Phone form (pairing-code flow) -----------------------------------------
'auth-card.phone.title': 'Вход по номеру телефона',
'auth-card.phone.label': 'Номер телефона', 'auth-card.phone.label': 'Номер телефона',
'auth-card.phone.placeholder': '+79991234567', 'auth-card.phone.placeholder': '+79991234567',
// Подсказка, объясняющая что произойдёт после сабмита: мост создаст // Подсказка, объясняющая что произойдёт после сабмита: мост создаст
@ -95,96 +80,104 @@ export const RU = {
'auth-card.phone.submit': 'Получить код', 'auth-card.phone.submit': 'Получить код',
'auth-card.phone.cooldown': 'Повтор через {seconds} сек', 'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.', 'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
// --- Pairing-code form -------------------------------------------------
// --- Pairing-code panel --------------------------------------------------
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp', 'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
'auth-card.pairing-code.hint': 'auth-card.pairing-code.hint':
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».', 'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Связать по номеру телефона».',
'auth-card.pairing-code.preparing': 'Готовим код…',
'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.', 'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.',
'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}', 'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}',
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', 'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.', 'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».', 'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
'auth-card.pairing-code.step-3': 'auth-card.pairing-code.step-3':
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».', 'Нажмите «Привязка устройства», затем «Связать по номеру телефона».',
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.', 'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
// --- QR form ----------------------------------------------------------- 'auth-card.pairing-code.copy': 'Скопировать код',
'auth-card.pairing-code.copied': 'Скопировано',
// --- Shared form chrome ------------------------------------------------
'auth-card.cancel': 'Отмена',
'auth-card.waiting-hint': 'Мост ещё думает… ответ может идти до 30 секунд.',
// --- QR panel ------------------------------------------------------------
'auth-card.qr.title': 'Вход по QR-коду', 'auth-card.qr.title': 'Вход по QR-коду',
'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.', 'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.',
'auth-card.qr.preparing': 'Готовим QR-код…', 'auth-card.qr.preparing': 'Готовим QR-код…',
'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.', 'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.',
// Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по // Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в // расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в панели
// панели всегда свежий (мост шлёт m.replace edits на каждой ротации), // всегда свежий (long-poll приносит новый на каждой ротации), отсчёт
// отсчёт показывает оставшееся окно ВСЕГО входа. // показывает оставшееся окно ВСЕГО входа.
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}', 'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', 'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.', 'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.',
'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».', 'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».',
'auth-card.qr.step-3': 'Нажмите «Привязать устройство» и отсканируйте QR-код.', 'auth-card.qr.step-3': 'Нажмите «Привязка устройства» и отсканируйте QR-код.',
// --- Shared form chrome ------------------------------------------------
'auth-card.cancel': 'Отмена', // --- Global errors / notices ---------------------------------------------
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.', 'error.network': 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.',
// --- Inline errors ----------------------------------------------------- 'error.auth':
// login_failed reasons — мы сохраняем верхатимный текст ошибки от 'Не удалось подтвердить ваш аккаунт у моста. Обновите страницу; если не помогает — попробуйте позже.',
// upstream. Это даёт юзеру максимально точную диагностику без перевода, 'error.not-logged-in':
// которое может разъехаться с реальной причиной. Шаблон обёрнут. 'Сессия WhatsApp больше не активна. Вернитесь назад и привяжите аккаунт заново.',
'auth-error.login-failed': 'Не удалось войти: {reason}', 'error.openid-blocked':
'auth-error.invalid-value': 'Значение не принято: {reason}', 'Хост не выдал виджету разрешение на вход — в config.json у бота нет capability vojo.openid.',
'auth-error.submit-failed': 'WhatsApp не принял ввод: {reason}', 'error.rate-limited': 'WhatsApp просит подождать: слишком много запросов кода. Попробуйте позже.',
'auth-error.start-failed': 'Не удалось начать вход: {reason}', 'error.phone-invalid':
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'WhatsApp не принял этот номер. Укажите его в международном формате и попробуйте снова.',
'auth-error.login-in-progress': 'error.multidevice':
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.', 'На телефоне не включён мультиустройственный режим WhatsApp. Обновите приложение WhatsApp и попробуйте снова.',
'auth-error.max-logins': 'error.client-outdated':
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.', 'Мост устарел, и WhatsApp отказывает ему в подключении. Сообщите администратору Vojo.',
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.', 'error.pair-failed': 'WhatsApp не подтвердил привязку. Начните вход заново.',
// External-logout варианты — три причины, у каждой своя UX-формулировка. 'error.login-timeout': 'Время входа истекло. Начните вход заново.',
// «another_device» — другой связанный девайс отвязал нас (например, юзер 'error.login-restart': 'Сессия входа потерялась. Начните вход заново.',
// отвязал bridge с другого ноутбука). «phone_logged_out» — юзер вышел 'error.too-many-logins': 'Достигнут лимит привязанных аккаунтов. Сначала выйдите из текущего.',
// из WhatsApp на самом телефоне, что ломает все связанные устройства. 'error.generic': 'Что-то пошло не так: {reason}',
// «unknown» — fallback, в т.ч. для startup-нотисов «You're not logged 'notice.login-success': 'WhatsApp привязан! Чаты появятся в списке в течение минуты.',
// into WhatsApp». 'notice.logged-out': 'Сеанс WhatsApp завершён.',
'auth-error.external-logout.another-device':
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.', // --- Contacts ------------------------------------------------------------
'auth-error.external-logout.phone-logged-out': 'card.contacts.name': 'Контакты',
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.', 'card.contacts.desc': 'Записная книжка WhatsApp: поиск по имени или номеру',
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.', 'contacts.back': 'Назад',
// --- Logout ------------------------------------------------------------ 'contacts.search-placeholder': 'Имя или +номер…',
'card.logout.name': 'Выйти из WhatsApp', 'contacts.hint':
'card.logout.desc': 'Завершить сеанс на этом аккаунте', 'Это контакты вашей записной книжки WhatsApp. Выберите, с кем начать чат в Vojo, — остальные никуда не денутся.',
'card.logout.confirm-prompt': 'Точно выйти?', 'contacts.loading': 'Загружаем контакты…',
'card.logout.confirm-yes': 'Выйти', 'contacts.error': 'Не удалось загрузить контакты.',
'card.logout.confirm-no': 'Отмена', 'contacts.retry': 'Повторить',
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.', 'contacts.empty': 'В записной книжке WhatsApp пока пусто.',
// --- Diagnostics in transcript ---------------------------------------- 'contacts.empty-filtered': 'Никого не нашли с таким именем.',
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', 'contacts.start-chat': 'Начать чат',
'diag.ready': 'Готов отправлять команды.', 'contacts.open-chat': 'Открыть чат',
'diag.checking-status': 'Проверяю статус подключения…', 'contacts.creating': 'Создаём чат…',
'diag.send-failed': 'ошибка отправки: {message}', 'contacts.opening': 'Открываем…',
'diag.history-marker': '─── история ───', 'contacts.probe-check': 'Проверить {handle} в WhatsApp',
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.', 'contacts.probe-checking': 'Проверяем {handle}…',
// QR-сообщения никогда не выводятся целиком в transcript — body содержит 'contacts.probe-not-found': '{handle} не найден в WhatsApp.',
// raw whatsmeow handshake (включая adv-secret, который IS the login 'contacts.probe-found': 'Есть такой! Можно написать.',
// token). Сохранять его в DOM-логе виджета означало бы пережить мост- 'contacts.probe-self': 'Это ваш собственный номер.',
// редакцию. В логе только нейтральные диагностические строки. 'contacts.refresh': 'Обновить список',
'diag.qr-issued': 'QR-код обновлён.',
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.', // --- Account tab -----------------------------------------------------------
// Pairing-код — не такой же чувствительный как QR adv-secret (это 'account.state-bad':
// 8-символьный one-time pairing token, действителен ~3 минуты), но 'Мост сообщает о проблеме с подключением: {reason}. Попробуйте выйти и привязать WhatsApp заново.',
// всё равно по аналогии с QR не дублируем его в transcript — UI и так // TRANSIENT_DISCONNECT — телефон давно не в сети / keepalive потерян
// показывает код большим моноширинным текстом. В логе только нейтральная // (connector phoneping.go). Обычно чинится само, перепривязка не нужна.
// диагностика, чтобы trail был последовательный. 'account.state-transient':
'diag.pairing-code-issued': 'Код для входа выдан.', 'Мост временно потерял связь с WhatsApp. Проверьте, что телефон в сети, — обычно подключение восстанавливается само.',
// Connection warnings от connector handlewhatsapp.go — они не меняют
// state виджета, просто пишутся в transcript verbatim, чтобы юзер // --- Boot / config ---------------------------------------------------------
// понимал, что мост борется с подключением. 'boot.connecting': 'Подключение к мосту…',
'diag.connection-warning': '{text}', 'config.missing.title': 'Нужна настройка на сервере',
// External-logout transcript echo — короткая строка под красным 'config.missing.body':
// баннером. 'Виджет работает через API моста, но его адрес не задан в конфигурации (experience.provisioningUrl в config.json) или не выдано разрешение vojo.openid.',
'diag.external-logout': 'WhatsApp разорвал сессию — нужен повторный вход.', 'error.retry': 'Повторить',
// --- Bootstrap failure ------------------------------------------------- // --- Bootstrap failure -------------------------------------------------
'bootstrap.failed': 'Widget не запустился', 'bootstrap.failed': 'Виджет не запустился',
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.', 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
} as const; } as const;

View file

@ -0,0 +1,821 @@
// Login flow over the bridgev2 v3 login API. The bridge owns the step
// machine (provisioning.go PostLoginStep); we render whatever step it
// returns (connector: mautrix-whatsapp pkg/connector/login.go):
//
// phone flow: user_input(phone_number) → display_and_wait(code)
// —long-poll→ complete
// qr flow: display_and_wait(qr) —long-poll→ rotated qr | complete
//
// Unlike the Telegram connector there are NO `.incorrect` retry steps:
// every rejected input or pairing failure is a RespError that deletes the
// login process server-side (provisioning.go PostLoginSubmitInput /
// PostLoginWait error paths). The phone form keeps the typed number on
// screen with an inline error and transparently starts a fresh process on
// resubmit; QR/code failures drop back to the action cards with a notice.
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
// validation. Sufficient for «is this a plausible phone number?» — the
// bridge does the authoritative validation server-side.
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
import {
ProvisioningClient,
ProvisioningError,
WA_FLOW_PHONE,
WA_FLOW_QR,
isNotFound,
type LoginStep,
} from './provisioning';
import { describeApiError } from './errors';
import type { T } from './i18n';
// --- Flow state -------------------------------------------------------------
export type LoginUi =
| { kind: 'idle' }
| { kind: 'starting'; flow: 'phone' | 'qr' }
| {
// The phone-number form — the only user_input step the WhatsApp
// connector ships (step fi.mau.whatsapp.login.phone).
kind: 'form';
loginId: string;
stepId: string;
fieldId: string;
busy: boolean;
error?: string;
/** The server-side login process died (its errors are terminal) the
* next submit transparently starts a fresh process. */
needsRestart?: boolean;
}
| { kind: 'qr'; loginId: string; stepId: string; url: string }
| { kind: 'code'; loginId: string; stepId: string; code: string };
type LoginFlowCallbacks = {
/** A `complete` step landed — refresh whoami and celebrate. */
onComplete: () => void;
/** Terminal flow error to surface outside the (now closed) form. */
onError: (message: string) => void;
};
export type LoginFlow = {
ui: LoginUi;
start: (flow: 'phone' | 'qr') => void;
submit: (value: string) => void;
cancel: () => void;
/** Pairing-code re-request cooldown deadline (WhatsApp rate-limits the
* PairPhone IQ hard), null when idle. */
phoneCooldownEnd: number | null;
/** Last phone number the user typed (display-formatted) survives
* cancelreopen so retrying during the cooldown doesn't force
* retyping. */
lastPhone: string;
rememberPhone: (value: string) => void;
};
// WhatsApp answers repeat pairing-code requests with rate-overlimit IQs
// (whatsmeow ErrIQRateOverLimit). 60 s between requests keeps us under the
// radar. Armed only when the bridge confirms it issued a code (the submit
// resolved into the display_and_wait code step).
const PHONE_COOLDOWN_MS = 60_000;
// --- Wait-loop resilience ----------------------------------------------------
// The display_and_wait long-poll dies whenever Android freezes the
// backgrounded WebView — and entering the pairing code REQUIRES leaving Vojo
// for the WhatsApp app, which kills the TCP connection within ~15 s. Since
// mautrix v0.28.1 the login process lives server-side with a 30-minute TTL
// (provisioninglogin.go: Wait runs on login.Ctx, not the request context),
// so a dropped poll is reattachable: the loop retries transport-shaped
// failures with backoff and wakes early when the tab returns to the
// foreground. On pre-v0.28.1 bridges the retry converges to an authoritative
// 404 (the old framework deleted the process with the connection) — same
// honest error as before, no regression.
const WAIT_RETRY_BASE_MS = 2_000;
const WAIT_RETRY_MAX_MS = 15_000;
// Transport-shaped failures: fetch network death (TypeError), an abort that
// is NOT ours (the frozen WebView tearing the connection down, or the
// per-attempt poll timeout), or an errcode-LESS 5xx — a proxy-shaped
// 502/503/504 from Caddy with a non-JSON body. Anything carrying an errcode
// is a RespError the bridge itself wrote and is an authoritative verdict on
// the login — NOT retryable. Notably M_BAD_STATE (step machine advanced
// without us) and the framework's LOGIN_TIMED_OUT ship as HTTP 500, so a
// bare status check would retry a dead login forever.
const isTransientWaitError = (err: unknown): boolean => {
if (err instanceof ProvisioningError) {
return err.httpStatus >= 500 && err.errcode === undefined;
}
return err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError');
};
// Backoff delay that resolves early when the document becomes visible again
// (snappy reattach after returning from the WhatsApp app) or when the loop
// is aborted (the caller re-checks the signal and exits).
const waitBeforeRetry = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
let timer: number | null = null;
let cleanup = () => {};
const done = () => {
cleanup();
resolve();
};
const onVisible = () => {
if (document.visibilityState === 'visible') done();
};
cleanup = () => {
if (timer !== null) window.clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
signal.removeEventListener('abort', done);
};
timer = window.setTimeout(done, ms);
document.addEventListener('visibilitychange', onVisible);
signal.addEventListener('abort', done);
});
export const useLoginFlow = (
client: ProvisioningClient,
t: T,
callbacks: LoginFlowCallbacks
): LoginFlow => {
const [ui, setUi] = useState<LoginUi>({ kind: 'idle' });
const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(null);
// Latest-wins guards for async work. Bumping the generation invalidates
// every in-flight continuation (long-poll loop, submit handlers);
// aborting the controller actually cancels the poll's fetch — which is
// ALSO the real server-side cancel: mautrix v0.27.0 has no /login/cancel
// route, but the aborted long-poll cancels the handler context and the
// bridge deletes the login process (provisioning.go PostLoginWait).
const generation = useRef(0);
const waitAbort = useRef<AbortController | null>(null);
const lastPhoneRef = useRef('');
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
useEffect(
() => () => {
generation.current += 1;
waitAbort.current?.abort();
},
[]
);
return useMemo<LoginFlow>(() => {
// Long-poll loop for both display panels. The QR token rotates server
// side (60 s for the first, 20 s after — connector qrIntervals); the
// pairing code never rotates, but the loop stays liberal and re-renders
// whatever display step comes back.
const runWaitLoop = (loginId: string, firstStepId: string, gen: number): void => {
waitAbort.current?.abort();
const controller = new AbortController();
waitAbort.current = controller;
void (async () => {
let stepId = firstStepId;
let retryDelayMs = WAIT_RETRY_BASE_MS;
for (;;) {
let step: LoginStep;
try {
step = await client.loginWait(loginId, stepId, controller.signal);
retryDelayMs = WAIT_RETRY_BASE_MS;
} catch (err) {
if (gen !== generation.current || controller.signal.aborted) return;
if (isTransientWaitError(err)) {
// The panel stays up; the next attempt either reattaches to
// the still-alive server-side step or gets an authoritative
// 4xx and ends the flow honestly.
await waitBeforeRetry(retryDelayMs, controller.signal);
if (gen !== generation.current || controller.signal.aborted) return;
retryDelayMs = Math.min(retryDelayMs * 2, WAIT_RETRY_MAX_MS);
continue;
}
// A 404 (or ALREADY_FINISHED) can mean two opposite things: the
// window expired, or the login COMPLETED while the WebView was
// frozen — a finished login is removed from the registry, so a
// late re-poll can't tell the difference. whoami is the
// authority on which way it went.
const gone =
isNotFound(err) ||
(err instanceof ProvisioningError &&
err.errcode === 'FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED');
if (gone) {
// The probe itself retries transport blips (a completed login
// must not be reported as «timed out» because one whoami GET
// hit a dead network); any authoritative answer breaks out.
let probeDelayMs = WAIT_RETRY_BASE_MS;
for (;;) {
try {
const whoami = await client.whoami();
if (gen !== generation.current || controller.signal.aborted) return;
if (whoami.logins && whoami.logins.length > 0) {
setUi({ kind: 'idle' });
callbacksRef.current.onComplete();
return;
}
break; // authoritative «no login» — the window really expired
} catch (probeErr) {
if (gen !== generation.current || controller.signal.aborted) return;
if (!isTransientWaitError(probeErr)) break;
await waitBeforeRetry(probeDelayMs, controller.signal);
if (gen !== generation.current || controller.signal.aborted) return;
probeDelayMs = Math.min(probeDelayMs * 2, WAIT_RETRY_MAX_MS);
}
}
} else {
// The login may still be alive server-side (e.g. M_BAD_STATE
// desync) — free the 30-minute slot. Best-effort, 404 on old
// bridges is swallowed.
void client.loginCancel(loginId).catch(() => undefined);
}
setUi({ kind: 'idle' });
callbacksRef.current.onError(
gone ? t('error.login-timeout') : describeApiError(err, t)
);
return;
}
if (gen !== generation.current) return;
if (step.type === 'display_and_wait') {
const data = step.display_and_wait?.data;
if (step.display_and_wait?.type === 'qr' && data) {
stepId = step.step_id;
setUi({ kind: 'qr', loginId, stepId: step.step_id, url: data });
continue;
}
if (step.display_and_wait?.type === 'code' && data) {
stepId = step.step_id;
setUi({ kind: 'code', loginId, stepId: step.step_id, code: data });
continue;
}
setUi({ kind: 'idle' });
callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
return;
}
applyStep(step, gen);
return;
}
})();
};
const applyStep = (step: LoginStep, gen: number): void => {
if (gen !== generation.current) return;
if (step.type === 'complete') {
setUi({ kind: 'idle' });
callbacksRef.current.onComplete();
return;
}
if (step.type === 'user_input') {
const field = step.user_input?.fields?.[0];
if (!field || field.type !== 'phone_number') {
setUi({ kind: 'idle' });
callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
void client.loginCancel(step.login_id).catch(() => undefined);
return;
}
setUi({
kind: 'form',
loginId: step.login_id,
stepId: step.step_id,
fieldId: field.id,
busy: false,
});
return;
}
if (step.type === 'display_and_wait') {
const data = step.display_and_wait?.data;
if (step.display_and_wait?.type === 'qr' && data) {
setUi({ kind: 'qr', loginId: step.login_id, stepId: step.step_id, url: data });
runWaitLoop(step.login_id, step.step_id, gen);
return;
}
if (step.display_and_wait?.type === 'code' && data) {
setUi({ kind: 'code', loginId: step.login_id, stepId: step.step_id, code: data });
runWaitLoop(step.login_id, step.step_id, gen);
return;
}
}
// cookies / unknown display types — nothing the WhatsApp connector
// ships today. Bail out coherently instead of rendering nothing.
setUi({ kind: 'idle' });
callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
void client.loginCancel(step.login_id).catch(() => undefined);
};
const start = (flow: 'phone' | 'qr'): void => {
generation.current += 1;
const gen = generation.current;
setUi({ kind: 'starting', flow });
void client
.loginStart(flow === 'phone' ? WA_FLOW_PHONE : WA_FLOW_QR)
.then((step) => {
if (gen !== generation.current) {
// User cancelled while the start was in flight. Best-effort
// cleanup only: on the deployed bridge (mautrix v0.27.0, no
// cancel route) this 404s and the process orphans — bounded
// harm, whatsmeow closes its login socket itself when the QR
// window (~160 s) runs out.
void client.loginCancel(step.login_id).catch(() => undefined);
return;
}
applyStep(step, gen);
})
.catch((err) => {
if (gen !== generation.current) return;
setUi({ kind: 'idle' });
callbacksRef.current.onError(describeApiError(err, t));
});
};
const submit = (value: string): void => {
if (ui.kind !== 'form' || ui.busy) return;
const snapshot = ui;
const gen = generation.current;
setUi({ ...snapshot, busy: true, error: undefined });
void (async () => {
try {
let step: LoginStep;
if (snapshot.needsRestart) {
// Previous process died on a terminal error — restart
// transparently so «fix the typo and resubmit» just works.
const fresh = await client.loginStart(WA_FLOW_PHONE);
if (gen !== generation.current) {
// Cancelled while the restart was in flight — don't go on to
// request a pairing code for a flow nobody is looking at.
void client.loginCancel(fresh.login_id).catch(() => undefined);
return;
}
const freshField = fresh.user_input?.fields?.[0];
if (fresh.type !== 'user_input' || !freshField) {
applyStep(fresh, gen);
return;
}
step = await client.loginSubmitInput(fresh.login_id, fresh.step_id, {
[freshField.id]: value,
});
} else {
step = await client.loginSubmitInput(snapshot.loginId, snapshot.stepId, {
[snapshot.fieldId]: value,
});
}
// The phone submit resolving into the display step means WhatsApp
// issued a pairing code — arm the re-request cooldown BEFORE the
// stale-generation check: a cancel that raced the submit doesn't
// un-issue the code, and the cooldown must survive cancel→retry
// (WhatsApp rate-limits repeat PairPhone requests).
if (step.type === 'display_and_wait') {
setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
}
if (gen !== generation.current) return;
applyStep(step, gen);
} catch (err) {
if (gen !== generation.current) return;
// Every submit error is terminal server-side (the bridge deleted
// the process) — keep the form open with the inline error and
// restart transparently on the next submit.
setUi({ ...snapshot, busy: false, error: describeApiError(err, t), needsRestart: true });
}
})();
};
const cancel = (): void => {
generation.current += 1;
// Aborting the display_and_wait long-poll IS the real server-side
// cancel on the deployed bridge: PostLoginWait sees its request
// context die and deletes the login process. The loginCancel POST
// below covers only future bridge versions (v0.27.0 has no such
// route) — on the QR/code panels it's redundant, and for a phone-form
// process (no poll to abort) the orphan is bounded: the process holds
// no socket until a number is submitted.
waitAbort.current?.abort();
// 'starting' has no loginId yet — the stale-generation check in
// start().then() handles the just-created process when it lands.
// phoneCooldownEnd deliberately SURVIVES cancel: it guards WhatsApp's
// pairing-code rate limit, and a cancel→restart loop must not reset it
// (the submit button labels the remaining wait, so this reads as
// intended).
const loginId =
ui.kind === 'form' || ui.kind === 'qr' || ui.kind === 'code' ? ui.loginId : undefined;
if (loginId) void client.loginCancel(loginId).catch(() => undefined);
setUi({ kind: 'idle' });
};
return {
ui,
start,
submit,
cancel,
phoneCooldownEnd,
lastPhone: lastPhoneRef.current,
rememberPhone: (value: string) => {
lastPhoneRef.current = value;
},
};
// `ui` MUST stay in the deps: submit/cancel read it via closure capture
// (not refs), so dropping it would freeze them on a stale snapshot.
// Regenerating the flow object per ui change is cheap — nothing
// downstream memoizes on its identity.
}, [client, t, ui, phoneCooldownEnd]);
};
// --- Shared form helpers ------------------------------------------------------
// Hint shown when a submit round-trip is slow (WhatsApp-side latency).
const STILL_WAITING_DELAY_MS = 8_000;
const useStillWaiting = (active: boolean): boolean => {
const [show, setShow] = useState(false);
useEffect(() => {
setShow(false);
if (!active) return undefined;
const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
return () => window.clearTimeout(timer);
}, [active]);
return show;
};
// Tick once per second while a future timestamp is still in the future.
export const useCooldownSeconds = (until: number | null): number => {
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
const [seconds, setSeconds] = useState(compute);
useEffect(() => {
if (!until) {
setSeconds(0);
return undefined;
}
setSeconds(compute());
const timer = window.setInterval(() => {
const next = Math.max(0, Math.ceil((until - Date.now()) / 1000));
setSeconds(next);
if (next <= 0) window.clearInterval(timer);
}, 1000);
return () => window.clearInterval(timer);
// `compute` is referentially fresh each render but captures `until`;
// the effect only needs to re-run when `until` itself changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [until]);
return seconds;
};
// --- Phone form ---------------------------------------------------------------
// Minimum digit count before we'd dare call a number «invalid» — below this
// the user is still typing the country prefix.
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
const phoneToE164 = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.length === 0) return '';
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
};
// AsYouType is stateful — use a fresh instance per call so mid-string edits
// (paste, backspace) can't desync the formatter from the input value.
type PhoneFormat = { formatted: string; country: string | undefined };
const formatPhoneInput = (raw: string): PhoneFormat => {
const e164 = phoneToE164(raw);
if (!e164) return { formatted: '', country: undefined };
const formatter = new AsYouType();
const formatted = formatter.input(e164);
return { formatted, country: formatter.getCountry() };
};
// ISO 3166-1 alpha-2 → regional-indicator emoji. 'RU' → 🇷🇺.
const countryToFlagEmoji = (cc: string | undefined): string => {
if (!cc || cc.length !== 2) return '';
const codePoints = cc
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
type FormProps = {
flow: LoginFlow;
t: T;
};
export const PhoneForm = ({ flow, t }: FormProps) => {
const ui = flow.ui.kind === 'form' ? flow.ui : null;
// Pre-fill the number the user typed in a previous attempt — a cancel
// during the pairing-code cooldown shouldn't cost them the input.
const [value, setValue] = useState(() => flow.lastPhone);
const [country, setCountry] = useState<string | undefined>(() =>
flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined
);
const inputRef = useRef<HTMLInputElement | null>(null);
const busy = ui?.busy ?? false;
const stillWaiting = useStillWaiting(busy);
const cooldownSeconds = useCooldownSeconds(flow.phoneCooldownEnd);
const inCooldown = cooldownSeconds > 0;
useEffect(() => {
inputRef.current?.focus();
}, []);
const e164 = phoneToE164(value);
const digitsCount = e164.replace('+', '').length;
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
// Soft hint, not a hard gate — libphonenumber metadata lags newly
// allocated pools and the bridge has the authoritative word.
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
const onSubmit = (event: Event) => {
event.preventDefault();
if (!e164 || busy || inCooldown || !hasEnoughDigits) return;
flow.rememberPhone(value);
flow.submit(e164);
};
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
const flagEmoji = countryToFlagEmoji(country);
const error = ui?.error;
return (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.phone.title')}</div>
<label class="auth-card-hint" for="auth-phone-input">
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
{flagEmoji ? (
<span class="auth-phone-flag" aria-hidden="true">
{flagEmoji}
</span>
) : null}
<input
id="auth-phone-input"
ref={inputRef}
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
const next = formatPhoneInput(raw);
setValue(next.formatted);
setCountry(next.country);
}}
disabled={busy}
/>
</div>
<button type="submit" class="btn-primary" disabled={busy || inCooldown || !hasEnoughDigits}>
{submitLabel}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{showInvalidHint && !error ? (
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
) : null}
{error ? <div class="auth-card-error">{error}</div> : null}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- Display panels (QR / pairing code) ----------------------------------------
// Server-side login window: whatsmeow issues ~6 QR codes on a fixed
// schedule — 60 s for the first + 20 s for each of the rest (connector
// qrIntervals, login.go:233) — and closes the login socket when they run
// out. The pairing code lives in the same window (whatsmeow PairPhone doc).
// Soft countdown — at zero we surface a retry hint; the server kills the
// process on its own and the long-poll reports it.
const LOGIN_WINDOW_MS = 160 * 1000;
// Shared 1 Hz countdown across the whole login window. The first-shown
// timestamp survives QR rotations — the panel stays mounted while only the
// payload changes, and the countdown tracks the WHOLE login window, not the
// validity of one displayed token.
const useLoginWindow = (): { remainingSeconds: number; expired: boolean } => {
const [firstShownAt] = useState(() => Date.now());
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const elapsed = now - firstShownAt;
return {
remainingSeconds: Math.max(0, Math.ceil((LOGIN_WINDOW_MS - elapsed) / 1000)),
expired: elapsed >= LOGIN_WINDOW_MS,
};
};
// Error-correction level M: more glare-resilient than L, smaller modules
// than Q. WhatsApp QR payloads (ref,noise-pubkey,identity-pubkey,adv-secret
// in base64) are long — M keeps the module grid scannable at 232 px.
const buildQrModules = (data: string): boolean[][] | null => {
if (!data) return null;
try {
const qr = qrcodeGenerator(0, 'M');
qr.addData(data);
qr.make();
const count = qr.getModuleCount();
const matrix: boolean[][] = [];
for (let r = 0; r < count; r += 1) {
const row: boolean[] = [];
for (let c = 0; c < count; c += 1) {
row.push(qr.isDark(r, c));
}
matrix.push(row);
}
return matrix;
} catch {
return null;
}
};
// Render the QR matrix as <rect>s inside an SVG. No dangerouslySetInnerHTML,
// no external rendering service — the QR payload contains the adv-secret
// that IS the login token and must never leave the iframe.
type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string };
const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => {
const count = matrix.length;
const margin = 4;
const totalUnits = count + margin * 2;
const cellPx = pixelSize / totalUnits;
const rects: ComponentChildren[] = [];
for (let r = 0; r < count; r += 1) {
for (let c = 0; c < count; c += 1) {
if (!matrix[r][c]) continue;
rects.push(
<rect
key={`${r}-${c}`}
x={(c + margin) * cellPx}
y={(r + margin) * cellPx}
width={cellPx + 0.5 /* +0.5 px overlap to kill subpixel gaps on Android */}
height={cellPx + 0.5}
fill="#000"
/>
);
}
}
return (
<svg
width={pixelSize}
height={pixelSize}
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
role="img"
aria-label={ariaLabel}
>
{rects}
</svg>
);
};
type QrPanelProps = {
url: string;
t: T;
onCancel: () => void;
};
export const QrPanel = ({ url, t, onCancel }: QrPanelProps) => {
const { remainingSeconds, expired } = useLoginWindow();
const matrix = useMemo(() => buildQrModules(url), [url]);
return (
<div class="auth-card auth-card-qr">
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
<div class="auth-card-qr-frame">
{matrix ? (
// The aria-label describes the PURPOSE of the QR, not its contents —
// the payload itself is the login secret.
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
) : (
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
<span class="dot" />
{t('auth-card.qr.preparing')}
</div>
)}
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.qr.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
)}
<ol class="auth-card-qr-steps">
<li>{t('auth-card.qr.step-1')}</li>
<li>{t('auth-card.qr.step-2')}</li>
<li>{t('auth-card.qr.step-3')}</li>
</ol>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// Clipboard write with a WebView-safe fallback chain: the async Clipboard
// API needs a secure context plus the host iframe's clipboard-write
// permission (BotWidgetEmbed sets `allow="clipboard-write"`); WebViews that
// reject it fall back to the synchronous execCommand path, which works
// inside a user-gesture handler.
const copyText = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
/* fall through to execCommand */
}
try {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const ok = document.execCommand('copy');
document.body.removeChild(area);
return ok;
} catch {
return false;
}
};
// Pairing-code panel — the WhatsApp-specific second display type. The
// 8-character code (XXXX-XXXX, whatsmeow pair-code.go) is typed into the
// WhatsApp app — usually on the SAME phone, so the copy button matters:
// the user switches apps and pastes instead of memorizing 8 characters.
type CodePanelProps = {
code: string;
t: T;
onCancel: () => void;
};
const COPIED_FEEDBACK_MS = 2_000;
export const CodePanel = ({ code, t, onCancel }: CodePanelProps) => {
const { remainingSeconds, expired } = useLoginWindow();
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
},
[]
);
const onCopy = () => {
// WhatsApp's pairing input may not strip the display dash on paste —
// copy the bare 8 characters.
void copyText(code.replace(/-/g, '')).then((ok) => {
if (!ok) return;
setCopied(true);
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
});
};
return (
<div class="auth-card auth-card-qr">
<div class="auth-card-title">{t('auth-card.pairing-code.title')}</div>
<div class="auth-card-hint">{t('auth-card.pairing-code.hint')}</div>
<div class="auth-card-code-frame" role="status" aria-label={t('auth-card.pairing-code.aria')}>
<span class="auth-card-code-value">{code}</span>
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.pairing-code.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.pairing-code.expired')}</div>
)}
<ol class="auth-card-qr-steps">
<li>{t('auth-card.pairing-code.step-1')}</li>
<li>{t('auth-card.pairing-code.step-2')}</li>
<li>{t('auth-card.pairing-code.step-3')}</li>
<li>{t('auth-card.pairing-code.step-4')}</li>
</ol>
<div class="auth-card-row">
<button type="button" class="btn-primary" onClick={onCopy}>
{copied ? t('auth-card.pairing-code.copied') : t('auth-card.pairing-code.copy')}
</button>
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};

View file

@ -2,15 +2,29 @@ import { render } from 'preact';
import { readBootstrap } from './bootstrap'; import { readBootstrap } from './bootstrap';
import { App } from './App'; import { App } from './App';
import { createT } from './i18n'; import { createT } from './i18n';
import { WidgetApi, buildCapabilities } from './widget-api'; import { WidgetApi } from './widget-api';
import './styles.css'; import './styles.css';
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the // Input-mode detector for hover styling. CSS gates `:hover` and
// full rationale. Default to 'mouse'; the capture-phase pointerdown // `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's
// listener flips to 'touch' on the first non-mouse pointerType. // Android Chromium WebView synthesises `:hover` on the focused element
// matchMedia guessing was dropped — every variant // after a tap and never clears it until the next interaction elsewhere —
// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`) // without the gate, every tap leaves a sticky hover state on the tapped
// is mis-reported on at least one shipping device. // card («card greys out after tap and only un-greys when you tap a
// different button»).
//
// Truth comes from `pointerdown.pointerType`. The capture-phase listener
// runs in the same task as any post-tap `:hover` synthesis, so a touch
// tap on Android lands in 'touch' mode in the same render frame as the
// synthesised hover would paint.
//
// Initial mode is plain 'mouse' — matchMedia-based guessing was tried and
// dropped: every interaction-media query is mis-reported on at least one
// shipping device (see git history for the survey). Defaulting to 'mouse'
// is strictly no worse on any device: a desktop user gets hover from frame
// zero, and a touch user cannot trigger `:hover` before tapping — by the
// time the first tap fires, our listener has already moved the attribute
// to 'touch'.
const setInputMode = (mode: 'touch' | 'mouse'): void => { const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode; document.documentElement.dataset.input = mode;
}; };
@ -52,19 +66,12 @@ if (!result.ok) {
// through the wrong palette. // through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme; document.documentElement.dataset.theme = result.bootstrap.theme;
// Instantiate the WidgetApi BEFORE React render. The constructor attaches // Instantiate the WidgetApi BEFORE the first render. The constructor
// the `window.addEventListener('message', ...)` listener synchronously, // attaches the `window.addEventListener('message', ...)` listener
// so by the time the host's ClientWidgetApi fires its capabilities // synchronously, so by the time the host's ClientWidgetApi fires its
// request on iframe `load` we're already listening. // capabilities request on iframe `load` we're already listening. On a
// // cached-bundle remount the request can race ahead of any useEffect —
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which // construction at module-load closes that window.
// runs AFTER React's first commit. On a fresh mount the bundle parse + const api = new WidgetApi(result.bootstrap);
// initial render took long enough for the host's request to arrive
// after the listener was attached, so it worked by accident. On the
// *second* mount (after «Show chat» → «Show widget») the bundle is
// browser-cached and parses near-instantly; the host's request raced
// ahead of useEffect, the listener missed it, and capability handshake
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
render(<App bootstrap={result.bootstrap} api={api} />, root); render(<App bootstrap={result.bootstrap} api={api} />, root);
} }

View file

@ -0,0 +1,355 @@
// Typed client for the mautrix bridgev2 provisioning HTTP API
// (`/_matrix/provision/v3/*`, exposed by Caddy at bootstrap.provisioningUrl).
//
// Wire contract extracted from the bridge sources (mautrix-whatsapp
// v0.2604.0, mautrix-go v0.27.0):
// maunium.net/go/mautrix bridgev2/matrix/provisioning.go (routes + auth)
// bridgev2/provisionutil/{listcontacts,resolveidentifier}.go (response shapes)
// mautrix-whatsapp pkg/connector/login.go (flow/step/field ids)
// mautrix-whatsapp pkg/connector/startchat.go (identifier rules)
//
// Auth: every request carries `Authorization: Bearer openid:<token>` — an
// MSC1960 OpenID token requested from the host. The bridge validates it
// against the homeserver's federation API (AuthMiddleware →
// checkFederatedMatrixAuth) and caches the validation for an hour. The
// `user_id` query param tells the middleware whose identity to verify.
import type { OpenIdCredentials } from './widget-api';
// --- Response types --------------------------------------------------------
export type BridgeStateInfo = {
state_event?: string;
error?: string;
message?: string;
reason?: string;
};
export type WhoamiLogin = {
id: string;
name?: string;
profile?: {
phone?: string;
email?: string;
username?: string;
name?: string;
avatar?: string;
};
state?: BridgeStateInfo;
space_room?: string;
};
export type LoginFlow = { id: string; name?: string; description?: string };
export type Whoami = {
network?: { displayname?: string };
login_flows?: LoginFlow[];
homeserver?: string;
bridge_bot?: string;
command_prefix?: string;
management_room?: string;
logins?: WhoamiLogin[];
};
export type Contact = {
/** Network user id bare phone digits for regular contacts
* (waid MakeUserID), `lid-…` for hidden-number contacts, `bot-…` for
* WhatsApp bots. Accepted by resolve_identifier / create_dm as-is. */
id: string;
name?: string;
avatar_url?: string;
/** URI-style identifiers: `tel:+<digits>`; empty for lid-/bot- contacts
* (connector userinfo.go contactToUserInfo). */
identifiers?: string[];
/** Ghost MXID (`@whatsapp_<id>:vojo.chat`). */
mxid?: string;
/** Existing DM portal room — present ⇒ the chat is already in Vojo. */
dm_room_mxid?: string;
};
export type LoginInputField = {
type: string; // phone_number | ...
id: string; // submit-map key
name?: string;
description?: string;
pattern?: string;
};
export type LoginStep = {
/** Present on /login/start and /login/step responses (RespSubmitLogin). */
login_id: string;
type: 'user_input' | 'display_and_wait' | 'cookies' | 'complete';
step_id: string;
instructions?: string;
user_input?: { fields: LoginInputField[] };
display_and_wait?: {
type: 'qr' | 'emoji' | 'code' | 'nothing';
data?: string;
image_url?: string;
};
complete?: { user_login_id?: string };
};
// WhatsApp connector constants (pkg/connector/login.go:30-31). Unlike the
// Telegram connector there are NO `.incorrect` retry step variants — every
// rejected input is a RespError that deletes the login process server-side.
export const WA_FLOW_QR = 'qr';
export const WA_FLOW_PHONE = 'phone';
// --- Errors ----------------------------------------------------------------
export class ProvisioningError extends Error {
public readonly errcode?: string;
public readonly httpStatus: number;
public constructor(httpStatus: number, errcode: string | undefined, message: string) {
super(message);
this.name = 'ProvisioningError';
this.httpStatus = httpStatus;
this.errcode = errcode;
}
}
export const isNotFound = (err: unknown): boolean =>
err instanceof ProvisioningError && err.httpStatus === 404;
// Any 401 means «refresh the OpenID token and retry once» — matching the
// errcodes alone would silently strand flows if the bridge (or a proxy in
// front of it) ever returns a 401 with a different body.
const isAuthError = (err: unknown): boolean =>
err instanceof ProvisioningError &&
(err.httpStatus === 401 ||
err.errcode === 'M_MISSING_TOKEN' ||
err.errcode === 'M_UNKNOWN_TOKEN');
// --- Client ----------------------------------------------------------------
const DEFAULT_TIMEOUT_MS = 20_000;
// display_and_wait long-polls block server-side until the QR token rotates
// (60 s for the first, 20 s after — connector qrIntervals) or the login
// resolves — no client timeout, only caller aborts.
type RequestOptions = {
body?: unknown;
signal?: AbortSignal;
/** null disables the timeout (long-poll). */
timeoutMs?: number | null;
};
export class ProvisioningClient {
private token: string | null = null;
private tokenExpiresAt = 0;
private tokenInFlight: Promise<string> | null = null;
public constructor(
private readonly baseUrl: string,
private readonly userId: string,
private readonly fetchCredentials: () => Promise<OpenIdCredentials>
) {}
// -- auth plumbing --
private getToken(force = false): Promise<string> {
if (!force && this.token && Date.now() < this.tokenExpiresAt) {
return Promise.resolve(this.token);
}
// Collapse concurrent refreshes (e.g. contacts + whoami racing on boot)
// into one host round-trip.
if (!this.tokenInFlight) {
this.tokenInFlight = this.fetchCredentials()
.then((creds) => {
this.token = creds.accessToken;
// Refresh a minute early so a token can't expire mid-request.
this.tokenExpiresAt = Date.now() + Math.max(30, creds.expiresIn - 60) * 1000;
return this.token;
})
.finally(() => {
this.tokenInFlight = null;
});
}
return this.tokenInFlight;
}
private async request<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
const attempt = async (forceToken: boolean): Promise<T> => {
const token = await this.getToken(forceToken);
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set('user_id', this.userId);
const controller = new AbortController();
const onCallerAbort = () => controller.abort();
opts.signal?.addEventListener('abort', onCallerAbort);
if (opts.signal?.aborted) controller.abort();
const timeoutMs = opts.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : opts.timeoutMs;
const timer =
timeoutMs === null ? null : window.setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url.toString(), {
method,
signal: controller.signal,
headers: {
Authorization: `Bearer openid:${token}`,
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = undefined;
}
if (!res.ok) {
const errBody = (parsed ?? {}) as { errcode?: string; error?: string };
throw new ProvisioningError(
res.status,
errBody.errcode,
errBody.error ?? `HTTP ${res.status}`
);
}
return parsed as T;
} finally {
if (timer !== null) window.clearTimeout(timer);
opts.signal?.removeEventListener('abort', onCallerAbort);
}
};
try {
return await attempt(false);
} catch (err) {
// Token expired/invalidated between cache and use — refresh once and
// retry. Never retried for long-polls mid-flight aborts (those throw
// AbortError, not ProvisioningError).
if (isAuthError(err)) return attempt(true);
throw err;
}
}
// -- API surface --
public whoami(): Promise<Whoami> {
return this.request<Whoami>('GET', '/v3/whoami');
}
public async listContacts(): Promise<Contact[]> {
const resp = await this.request<{ contacts?: Contact[] }>('GET', '/v3/contacts');
return resp.contacts ?? [];
}
/** Resolve a phone number (`+7`). Returns null when the number is not on
* WhatsApp (the bridge answers 404 M_NOT_FOUND, startchat.go:84). */
public async resolveIdentifier(
identifier: string,
signal?: AbortSignal
): Promise<Contact | null> {
try {
return await this.request<Contact>(
'GET',
`/v3/resolve_identifier/${encodeURIComponent(identifier)}`,
{ signal }
);
} catch (err) {
if (isNotFound(err)) return null;
throw err;
}
}
/** Resolve + ensure the DM portal room exists. `dm_room_mxid` in the
* response is the room to open. */
public createDm(identifier: string): Promise<Contact> {
return this.request<Contact>('POST', `/v3/create_dm/${encodeURIComponent(identifier)}`, {
// Portal creation = room create + initial sync on the bridge side;
// give it more headroom than a plain GET.
timeoutMs: 45_000,
});
}
public loginStart(flowId: string): Promise<LoginStep> {
return this.request<LoginStep>('POST', `/v3/login/start/${encodeURIComponent(flowId)}`, {
// Phone flow start is instant, QR start connects to WhatsApp and
// waits for whatsmeow's first QR batch (≤15 s connect deadline in the
// connector, login.go LoginConnectWait) — allow for a slow handshake.
timeoutMs: 45_000,
});
}
public loginSubmitInput(
loginId: string,
stepId: string,
fields: Record<string, string>
): Promise<LoginStep> {
return this.request<LoginStep>(
'POST',
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(stepId)}/user_input`,
// The phone submit connects to WhatsApp and requests a pairing code
// (PairPhone IQ round-trip) before responding.
{ body: fields, timeoutMs: 45_000 }
);
}
/** Long-poll a display_and_wait step (QR or pairing code). Resolves with
* the next step: a rotated QR, or complete. The 200 s cap sits above every
* legitimate server-side wait (QR rotations 60 s, the whole whatsmeow
* pairing window is 160 s) hitting it means the server-side handler is
* wedged (stale lock on an old bridge); the wait loop folds the resulting
* AbortError into its retry path, which converges to an authoritative
* answer. */
public loginWait(loginId: string, stepId: string, signal: AbortSignal): Promise<LoginStep> {
return this.request<LoginStep>(
'POST',
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
stepId
)}/display_and_wait`,
{ signal, timeoutMs: 200_000 }
);
}
/** Best-effort: mautrix v0.27.0 (what mautrix-whatsapp v26.04 pins) has NO
* cancel route this 404s there and works on newer bridges. The real
* cancellation path is aborting the display_and_wait long-poll: the
* server's Wait sees the context cancel and deletes the login process.
* Callers must swallow rejections. */
public loginCancel(loginId: string): Promise<void> {
return this.request<void>('POST', `/v3/login/cancel/${encodeURIComponent(loginId)}`);
}
public logout(loginId: string): Promise<void> {
return this.request<void>('POST', `/v3/logout/${encodeURIComponent(loginId)}`);
}
}
// --- Identifier helpers (shared by contacts search + probe) -----------------
/** Loose phone shape digits with optional separators. Normalised to
* `+<digits>` before hitting the API. WhatsApp has no usernames phone
* numbers are the ONLY probe-able identifier (connector validateIdentifer
* rejects anything with letters as «looks like email»). */
export const PHONE_RE = /^\+?[\d\s\-()]{7,20}$/;
export type ProbeIdentifier = { kind: 'phone'; value: string; display: string };
export const detectIdentifier = (raw: string): ProbeIdentifier | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (PHONE_RE.test(trimmed)) {
const digits = trimmed.replace(/[^\d]/g, '');
if (digits.length >= 7) {
return { kind: 'phone', value: `+${digits}`, display: `+${digits}` };
}
}
return null;
};
/** Pull the `+phone` display string out of a contact's URI-style
* identifiers (`tel:+…`). lid-/bot- contacts have none. */
export const contactHandles = (contact: Contact): { phone?: string } => {
let phone: string | undefined;
for (const id of contact.identifiers ?? []) {
if (id.startsWith('tel:')) phone = id.slice('tel:'.length);
}
return { phone };
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,205 @@
// Shared visual vocabulary: inline SVG icons (stroke-only, currentColor so
// they inherit the Dawn palette), the initials avatar, and the command-card
// building blocks reused across the login and contacts surfaces.
//
// Visual canon: «Боты» mockup at
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
// (BotsDesktop) — Dawn palette, fleet-violet accent, letter avatars,
// friendly cards, no terminal styling.
import type { ComponentChildren } from 'preact';
export const RefreshIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Triangle warning glyph — leads the WhatsApp-only About card (which
// carries `command-card warn` for the amber outline) and re-appears inside
// the AboutModal's risk-disclosure callout. Stroke-only so it picks up the
// amber tint via `currentColor` in either context.
export const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
export const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
export const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
export const SearchIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="9" cy="9" r="5.5" />
<line x1="13.2" y1="13.2" x2="17" y2="17" stroke-linecap="round" />
</svg>
);
export const BackIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12.5 4 L6.5 10 L12.5 16" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
export const UserIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="6.8" r="3.3" />
<path d="M3.8 17c.9-3 3.3-4.6 6.2-4.6s5.3 1.6 6.2 4.6" stroke-linecap="round" />
</svg>
);
export const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// --- Initials avatar --------------------------------------------------------
// Dawn accent rotation (same hues the design mock assigns to bot list
// entries). Hash by codepoints so a contact keeps its color across loads.
const AVATAR_COLORS = ['#9580ff', '#7ab6d9', '#d4b88a', '#7dd3a8', '#c08e7b', '#a59cff'];
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
h = (h * 31 + s.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise
}
return Math.abs(h);
};
export const avatarColor = (key: string): string =>
AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length];
export const initialsOf = (name: string | undefined, fallback = '?'): string => {
const trimmed = (name ?? '').trim();
if (!trimmed) return fallback;
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return trimmed.slice(0, 1).toUpperCase();
};
type AvatarProps = { name?: string; colorKey: string; size?: number; src?: string | null };
// Initials always render underneath; the photo (when the MSC4039 download
// resolved) layers on top, so a slow or failed download degrades to the
// colored-letter look instead of an empty box.
export const Avatar = ({ name, colorKey, size = 38, src }: AvatarProps) => (
<span
class="avatar"
aria-hidden="true"
style={{
width: `${size}px`,
height: `${size}px`,
fontSize: `${Math.round(size * 0.42)}px`,
background: avatarColor(colorKey),
}}
>
{initialsOf(name)}
{src ? <img class="avatar-img" src={src} alt="" loading="lazy" /> : null}
</span>
);
// --- Command card -----------------------------------------------------------
type CommandCardProps = {
icon: ComponentChildren;
name: string;
desc: string;
onClick: () => void;
danger?: boolean;
/** Amber accent the WhatsApp About card signalling the Meta-ToS risk
* disclosure behind it. */
warn?: boolean;
disabled?: boolean;
spinning?: boolean;
};
export const CommandCard = ({
icon,
name,
desc,
onClick,
danger,
warn,
disabled,
spinning,
}: CommandCardProps) => (
<button
class={`command-card${danger ? ' danger' : ''}${warn ? ' warn' : ''}${
spinning ? ' refreshing' : ''
}`}
type="button"
onClick={onClick}
disabled={disabled}
>
<span class="command-card-lead-icon" aria-hidden="true">
{icon}
</span>
<div class="command-card-body">
<div class="command-card-name">{name}</div>
<div class="command-card-desc">{desc}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// --- Status / notice --------------------------------------------------------
type StatusPillProps = {
tone: 'connected' | 'disconnected' | 'checking';
children: ComponentChildren;
};
export const StatusPill = ({ tone, children }: StatusPillProps) => (
<span class={`section-status ${tone}`} role="status">
<span class="dot" />
{children}
</span>
);
type NoticeProps = {
tone: 'error' | 'warn' | 'info';
children: ComponentChildren;
onDismiss?: () => void;
};
// Inline notice strip — the replacement for the old transcript pane. One
// line of feedback right where the action happened, dismissable, no log.
export const Notice = ({ tone, children, onDismiss }: NoticeProps) => (
<div class={`notice ${tone}`} role={tone === 'error' ? 'alert' : 'status'}>
<span class="notice-body">{children}</span>
{onDismiss ? (
<button type="button" class="notice-dismiss" onClick={onDismiss} aria-label="×">
×
</button>
) : null}
</div>
);
export const Spinner = () => <span class="spinner" aria-hidden="true" />;

View file

@ -1,33 +1,16 @@
// Minimal matrix-widget-api transport implemented inline. We don't pull // Minimal matrix-widget-api transport implemented inline. We don't pull
// the full SDK because: // the full SDK because the surface we use is tiny: the capability
// - it's CommonJS and forces ESM interop juggling that we hit on the // handshake (we request NO capabilities — the bridge is driven over its
// dev fixture in the Telegram widget's M2 phase (esm.sh wrapping made // provisioning HTTP API, not over room events), theme_change pushes,
// WidgetApi unavailable as a constructor); // MSC1960 OpenID credentials, and two `io.vojo.bot-widget` side-channel
// - the surface we use is small: capabilities reply, theme_change reply, // verbs (open-external-url / open-matrix-to).
// send_event request, read_events request, get_openid request, live
// event delivery via send_event toWidget.
// //
// Protocol shapes match // Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts // node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// (in the host repo). Default request timeout on the host transport is // in the host repo.
// 10 s — keep that in mind for bridge-bot replies that take time.
import type { WidgetBootstrap } from './bootstrap'; import type { WidgetBootstrap } from './bootstrap';
export type RoomEvent = {
type: string;
event_id: string;
room_id: string;
sender: string;
origin_server_ts: number;
content: { msgtype?: string; body?: string; [k: string]: unknown };
unsigned: Record<string, unknown>;
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
// for forward-compat; the widget-side parser reads either.
redacts?: string;
};
type ToWidgetMessage = { type ToWidgetMessage = {
api: 'toWidget'; api: 'toWidget';
widgetId: string; widgetId: string;
@ -35,8 +18,6 @@ type ToWidgetMessage = {
action: string; action: string;
data: Record<string, unknown>; data: Record<string, unknown>;
// Present when this message IS a reply to a prior toWidget request. // Present when this message IS a reply to a prior toWidget request.
// Per matrix-widget-api PostmessageTransport: replies preserve the original
// `api` field and add `response`. Both directions follow the same shape.
response?: Record<string, unknown>; response?: Record<string, unknown>;
}; };
@ -49,16 +30,36 @@ type FromWidgetMessage = {
response?: Record<string, unknown>; response?: Record<string, unknown>;
}; };
export type Capability = string; export type OpenIdCredentials = {
accessToken: string;
/** Seconds until the token expires (Synapse default 3600). */
expiresIn: number;
matrixServerName: string;
};
export type WidgetApiEvents = { export type WidgetApiEvents = {
ready: () => void; ready: () => void;
liveEvent: (ev: RoomEvent) => void;
themeChange: (name: 'light' | 'dark') => void; themeChange: (name: 'light' | 'dark') => void;
}; };
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000; const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
// MSC1960 has a two-phase path: the host may reply `state: "request"`
// (user confirmation pending) and deliver the credentials later via a
// separate `openid_credentials` toWidget action. Vojo's host auto-grants
// for allowlisted first-party widgets, so phase 2 normally never happens —
// the generous timeout only matters if a future host adds a consent UI.
const OPENID_PHASE2_TIMEOUT_MS = 120_000;
const parseOpenIdCredentials = (raw: Record<string, unknown>): OpenIdCredentials | null => {
const accessToken = raw.access_token;
const matrixServerName = raw.matrix_server_name;
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
if (typeof matrixServerName !== 'string' || matrixServerName.length === 0) return null;
const expiresIn = typeof raw.expires_in === 'number' ? raw.expires_in : 3600;
return { accessToken, expiresIn, matrixServerName };
};
export class WidgetApi { export class WidgetApi {
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {}; private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
@ -67,14 +68,19 @@ export class WidgetApi {
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void } { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
>(); >();
// Phase-2 OpenID waiters keyed by the ORIGINAL get_openid requestId —
// the host's `openid_credentials` action carries it as
// `original_request_id`.
private readonly pendingOpenId = new Map<
string,
{ resolve: (v: OpenIdCredentials) => void; reject: (e: Error) => void; timer: number }
>();
private requestSeq = 0; private requestSeq = 0;
private isReady = false; private isReady = false;
public constructor( public constructor(private readonly bootstrap: WidgetBootstrap) {
private readonly bootstrap: WidgetBootstrap,
private readonly capabilities: Capability[]
) {
window.addEventListener('message', this.onMessage); window.addEventListener('message', this.onMessage);
} }
@ -82,6 +88,11 @@ export class WidgetApi {
window.removeEventListener('message', this.onMessage); window.removeEventListener('message', this.onMessage);
this.pending.forEach(({ reject }) => reject(new Error('disposed'))); this.pending.forEach(({ reject }) => reject(new Error('disposed')));
this.pending.clear(); this.pending.clear();
this.pendingOpenId.forEach(({ reject, timer }) => {
window.clearTimeout(timer);
reject(new Error('disposed'));
});
this.pendingOpenId.clear();
} }
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void { public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
@ -90,83 +101,82 @@ export class WidgetApi {
// `ready` is a one-shot lifecycle signal. If the handshake completed // `ready` is a one-shot lifecycle signal. If the handshake completed
// before this listener attached (cached-bundle race: host fires the // before this listener attached (cached-bundle race: host fires the
// capabilities request on iframe `load`, the WidgetApi catches and // capabilities request on iframe `load`, the WidgetApi catches and
// resolves it during script init, then React's useEffect runs *after* // resolves it during script init, then Preact's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously so // that and attaches the `ready` listener), replay synchronously.
// App.tsx still flips `handshakeOk` and fires `list-logins`.
if (event === 'ready' && this.isReady) { if (event === 'ready' && this.isReady) {
(listener as () => void)(); (listener as () => void)();
} }
} }
public sendText(body: string): Promise<{ event_id: string }> { // MSC1960: ask the host for OpenID credentials proving our Matrix
return this.fromWidget('send_event', { // identity. The provisioning client exchanges them with the bridge
type: 'm.room.message', // (`Authorization: Bearer openid:<token>`); they are NOT a Matrix access
content: { msgtype: 'm.text', body }, // token and grant no homeserver power. Rejects when the host driver
}) as Promise<{ event_id: string }>; // blocks the request (BotWidgetDriver gates on the `vojo.openid`
// capability opt-in in config.json).
public async getOpenIdCredentials(): Promise<OpenIdCredentials> {
const requestId = this.nextRequestId();
const reply = await this.fromWidget('get_openid', {}, requestId);
const state = reply.state;
if (state === 'allowed') {
const creds = parseOpenIdCredentials(reply);
if (!creds) throw new Error('host returned malformed OpenID credentials');
return creds;
}
if (state === 'request') {
// Phase 2: credentials arrive via the `openid_credentials` action.
return new Promise<OpenIdCredentials>((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingOpenId.delete(requestId);
reject(new Error('OpenID confirmation timed out'));
}, OPENID_PHASE2_TIMEOUT_MS);
this.pendingOpenId.set(requestId, { resolve, reject, timer });
});
}
throw new Error('OpenID request blocked by host');
}
// MSC4039 media download. The host driver only honours mxc URIs and
// substitutes a 96px crop thumbnail — this exists for avatars, not for a
// media viewer. Media on the homeserver is authenticated; the host fetches
// with its token and ships the bytes here as a Blob (structured clone).
public async downloadFile(mxcUri: string): Promise<Blob> {
const reply = await this.fromWidget('org.matrix.msc4039.download_file', {
content_uri: mxcUri,
});
const file = (reply as { file?: unknown }).file;
if (file instanceof Blob) return file;
if (file === undefined || file === null) throw new Error('host returned no file');
// Other XMLHttpRequestBodyInit shapes (ArrayBuffer, string) — normalize.
return new Blob([file as BlobPart]);
} }
// Open an external URL via the host. The host receives this on a // Open an external URL via the host. The host receives this on a
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from // SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
// matrix-widget-api's `fromWidget` so it doesn't route through // matrix-widget-api's `fromWidget` so it doesn't route through
// ClientWidgetApi's request/response machinery. // ClientWidgetApi's request/response machinery. Needed because
// // cross-origin iframes inside Capacitor's Android WebView silently drop
// Why this exists: cross-origin iframes inside Capacitor's Android // `<a target="_blank">` clicks.
// WebView silently drop `<a target="_blank">` clicks — the WebView
// doesn't have a multi-window concept, and the host's global
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
// inside the host document, not inside the iframe (cross-origin
// events don't bubble across the frame boundary). The widget posts
// this message instead; the host calls `openExternalUrl(url)` which
// routes to `Browser.open` on native and `window.open` on web.
public openExternalUrl(url: string): void { public openExternalUrl(url: string): void {
this.postSideChannel('open-external-url', { url });
}
// Ask the host to navigate to a Matrix room. The host validates the URL
// through `parseMatrixToRoom` and picks the destination surface (bridge
// space in Каналы / /direct/) — see BotWidgetMount.handleOpenMatrixToRoom.
public openMatrixRoom(roomId: string): void {
this.postSideChannel('open-matrix-to', {
url: `https://matrix.to/#/${encodeURIComponent(roomId)}`,
});
}
private postSideChannel(action: string, data: Record<string, unknown>): void {
window.parent.postMessage( window.parent.postMessage(
{ { api: 'io.vojo.bot-widget', action, data },
api: 'io.vojo.bot-widget',
action: 'open-external-url',
data: { url },
},
this.bootstrap.parentOrigin this.bootstrap.parentOrigin
); );
} }
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
// the management room and any other room the bot may have been moved to.
// Form-field submissions (phone number) go through this same helper because
// bridgev2's stored CommandState fallback only fires after queue.go:108
// routes the message — and that route also requires the prefix outside the
// management room.
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
return this.sendText(body);
}
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
// capability is MSC2762 timeline (already requested at construction). We
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
// ClientWidgetApi takes the modern code path that calls our driver's
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
// to chronological order is the caller's job.
//
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
// post-scan cleanup events. `msgtype` is honoured only for m.room.message
// (matches the driver's `readRoomTimeline` semantics).
public async readTimeline(opts: {
limit: number;
type?: 'm.room.message' | 'm.room.redaction';
msgtype?: 'm.text' | 'm.notice' | 'm.image';
}): Promise<RoomEvent[]> {
const data: Record<string, unknown> = {
type: opts.type ?? 'm.room.message',
limit: opts.limit,
room_ids: [this.bootstrap.roomId],
};
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
return (res.events as RoomEvent[] | undefined) ?? [];
}
private emit<K extends keyof WidgetApiEvents>( private emit<K extends keyof WidgetApiEvents>(
event: K, event: K,
...args: Parameters<WidgetApiEvents[K]> ...args: Parameters<WidgetApiEvents[K]>
@ -190,11 +200,10 @@ export class WidgetApi {
if (ev.origin !== this.bootstrap.parentOrigin) return; if (ev.origin !== this.bootstrap.parentOrigin) return;
// Source-window guard: every legit widget API message comes from the // Source-window guard: every legit widget API message comes from the
// host window that embedded our iframe — i.e. window.parent. A foreign // host window that embedded our iframe — i.e. window.parent. A foreign
// tab/frame on the same origin (think browser extension content // tab/frame on the same origin (browser extension content script,
// script, popup, or sibling iframe) could otherwise post a forged // popup, sibling iframe) could otherwise post a forged message that
// message that passes the origin check. We only accept messages // passes the origin check. The `widgetId` check below is a soft
// whose `source` is literally `window.parent`. The `widgetId` check // filter; this is the hard one.
// a few lines down is a soft filter; this is the hard one.
if (ev.source !== window.parent) return; if (ev.source !== window.parent) return;
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined; const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return; if (!msg || typeof msg !== 'object') return;
@ -230,7 +239,11 @@ export class WidgetApi {
if (!msg.requestId || !msg.action) return; if (!msg.requestId || !msg.action) return;
switch (msg.action) { switch (msg.action) {
case 'capabilities': { case 'capabilities': {
this.replyTo(msg, { capabilities: this.capabilities }); // No MSC2762 capabilities — no timeline reads, no message sends. The
// only capability the HTTP-transport widget needs is MSC4039 media
// download, used for contact/profile avatar thumbnails (media on the
// homeserver is authenticated and the widget holds no Matrix token).
this.replyTo(msg, { capabilities: ['org.matrix.msc4039.download_file'] });
return; return;
} }
case 'notify_capabilities': { case 'notify_capabilities': {
@ -242,7 +255,7 @@ export class WidgetApi {
return; return;
} }
case 'supported_api_versions': { case 'supported_api_versions': {
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] }); this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc1960'] });
return; return;
} }
case 'theme_change': { case 'theme_change': {
@ -252,30 +265,22 @@ export class WidgetApi {
this.replyTo(msg, {}); this.replyTo(msg, {});
return; return;
} }
case 'send_event': { case 'openid_credentials': {
// Live event push from host. Forward `m.room.message` (carries the // Phase-2 MSC1960 delivery after a `state: "request"` reply.
// bot's notices / errors / `m.image` QR-login broadcasts AND the const originalId = msg.data?.original_request_id;
// pairing-code text) AND `m.room.redaction` (post-scan QR cleanup, if (typeof originalId === 'string') {
// see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State const waiter = this.pendingOpenId.get(originalId);
// events (m.room.member) also arrive on this channel — we still if (waiter) {
// ignore them here. this.pendingOpenId.delete(originalId);
const data = msg.data as Partial<RoomEvent> | undefined; window.clearTimeout(waiter.timer);
if ( const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null;
data && if (creds) waiter.resolve(creds);
data.event_id && else waiter.reject(new Error('OpenID request blocked by host'));
(data.type === 'm.room.message' || data.type === 'm.room.redaction') }
) {
this.emit('liveEvent', data as RoomEvent);
} }
this.replyTo(msg, {}); this.replyTo(msg, {});
return; return;
} }
case 'update_state': {
// Initial room state push from host (m.room.member members).
// We don't use these yet; future milestones can use it for header chrome.
this.replyTo(msg, {});
return;
}
default: { default: {
// Be liberal — reply empty so the host's request promise resolves. // Be liberal — reply empty so the host's request promise resolves.
this.replyTo(msg, {}); this.replyTo(msg, {});
@ -285,10 +290,10 @@ export class WidgetApi {
private fromWidget( private fromWidget(
action: string, action: string,
data: Record<string, unknown> data: Record<string, unknown>,
requestId = this.nextRequestId()
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const requestId = this.nextRequestId();
this.pending.set(requestId, { resolve, reject }); this.pending.set(requestId, { resolve, reject });
this.postToHost({ this.postToHost({
api: 'fromWidget', api: 'fromWidget',
@ -306,22 +311,3 @@ export class WidgetApi {
}); });
} }
} }
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
// Anything else is silently dropped by the host's validateCapabilities.
//
// `m.image` and `m.room.redaction` are the QR-login additions (already in
// place from the Telegram widget M13). The host sanitizer for `m.image`
// strips `url` / `file` / `info`, leaving only `body` (the bridge encodes
// the QR payload there) plus `m.relates_to` / `m.new_content` for QR
// rotation edits. Redactions signal that the QR was consumed by a
// successful scan.
export const buildCapabilities = (roomId: string): Capability[] => [
`org.matrix.msc2762.timeline:${roomId}`,
'org.matrix.msc2762.send.event:m.room.message#m.text',
'org.matrix.msc2762.receive.event:m.room.message#m.text',
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
'org.matrix.msc2762.receive.event:m.room.message#m.image',
'org.matrix.msc2762.receive.event:m.room.redaction',
'org.matrix.msc2762.receive.state_event:m.room.member',
];

View file

@ -51,7 +51,8 @@
"experience": { "experience": {
"type": "matrix-widget", "type": "matrix-widget",
"url": "https://widgets.vojo.chat/whatsapp/index.html", "url": "https://widgets.vojo.chat/whatsapp/index.html",
"commandPrefix": "!wa" "provisioningUrl": "https://vojo.chat/_provision/whatsapp",
"capabilities": ["vojo.openid"]
} }
} }
], ],