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 WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
in the Vojo client. Drives the mautrix-whatsapp bridge bot
(`@whatsappbot:vojo.chat`) by sending bridgev2 commands in the control DM
and rendering the bot's text replies into a typed login flow.
in the Vojo client.
This is **not** a WhatsApp client — Vojo continues using the Matrix room
the bridge writes to. The widget is a panel that handles authentication
(QR scan or pairing code) and surfaces session status.
This is **not** a WhatsApp client. It's a control panel for the
mautrix-whatsapp bridge that talks to the bridge's **provisioning HTTP
API** (bridgev2 `/_matrix/provision/v3/*`, exposed by Caddy at
`https://vojo.chat/_provision/whatsapp`). It links the account (QR scan
or an 8-character pairing code typed into the WhatsApp app), shows the
linked account, lists WhatsApp contacts, resolves +phone numbers, and
creates DM portals on demand.
Auth: the widget requests MSC1960 OpenID credentials from the host
(`get_openid` over the widget API; granted by `BotWidgetDriver.askOpenID`
when config.json opts the bot into the `vojo.openid` capability) and
sends them to the bridge as `Authorization: Bearer openid:<token>`. The
OpenID token only proves identity — it is not a Matrix access token.
There is no bot text-command transport and no reply parsing: the legacy
`!wa`-command dialect (`bridge-protocol/`) was deleted when the bridge
API became reachable. The bot control DM still exists (BotShell needs a
room and the «Show chat» fallback), the widget just doesn't read or
write it — it requests **zero** MSC2762 capabilities.
## WhatsApp-specific contract notes
Extracted from mautrix-whatsapp v0.2604.0 (`pkg/connector/login.go`,
`startchat.go`) + mautrix-go v0.27.0 (`bridgev2/matrix/provisioning.go`):
- Login flows: `qr` and `phone`. The phone flow's submit answers with a
`display_and_wait` step of type **`code`** — an `XXXX-XXXX` pairing
code the user types into the WhatsApp app (NOT an SMS OTP entered into
the widget like Telegram's flow).
- The login window is ~160 s: whatsmeow issues ~6 QR tokens (60 s + 5×20 s)
and closes the socket when they run out; the pairing code lives in the
same window.
- There are **no** `.incorrect` retry steps: every rejected input is a
`FI.MAU.WHATSAPP.*` RespError that deletes the login process
server-side. The phone form transparently starts a fresh process on
resubmit.
- Login-liveness semantics differ by bridge generation. mautrix ≤ v0.28.0:
the login process dies with the poll's request context (and there is
**no** `/v3/login/cancel` route — cancelling works by aborting the
long-poll). mautrix ≥ v0.28.1 (`provisioninglogin.go`): the process
lives server-side with a 30-minute TTL, dropped polls reattach, and the
cancel route exists. The widget's wait loop is written for both: it
retries transport-shaped poll failures with backoff (Android freezes
the backgrounded WebView while the user enters the pairing code in
WhatsApp), wakes on return to foreground, and disambiguates a late
404 via whoami (the login may have COMPLETED while frozen).
- Identifiers are phone numbers only (`tel:+…`); no usernames. Contact
ids are bare phone digits (or `lid-`/`bot-` prefixed for
hidden-number/bot peers).
The About card and modal carry the **Meta-ToS risk disclosure**
(`warning.*` keys, amber warn styling, triangle icon) — WhatsApp's terms
of service forbid third-party clients and Meta may ban accounts for it.
This copy is a deliberate product/legal requirement: keep it intact when
restyling.
## Layout
```
src/
├── bootstrap.ts Parse URL params the host appends (mirrors BotWidgetEmbed.ts)
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK)
├── App.tsx UI: login forms, QR / pairing-code panels, transcript pane
├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts)
├── widget-api.ts Inline matrix-widget-api postMessage transport: handshake,
│ theme, MSC1960 get_openid, io.vojo.bot-widget verbs
├── provisioning.ts Typed client for the bridgev2 provisioning API + identifier
│ helpers (wire contract documented in the file header)
├── errors.ts Bridge/WhatsApp errcode → localized copy mapping
├── login.tsx Login flow over the v3 step machine + forms (phone form,
│ QR panel and pairing-code panel with long-poll)
├── contacts.tsx Contacts list, search-as-filter, phone probe, create DM
├── App.tsx Shell: boot/disconnected/connected phases, account view,
│ About modal with the Meta-ToS warning callout
├── ui.tsx Icons, initials avatar, command cards, notices
├── main.tsx Entry: init bootstrap, render App or diagnostic
├── styles.css Theme-aware CSS (Vojo Dawn palette)
├── state.ts Login state machine + hydrate-from-timeline
├── i18n/ Russian primary + English fallback
└── bridge-protocol/
├── types.ts LoginEvent discriminated union
├── parser.ts Dispatch shim
└── dialects/
└── bridgev2_v0264.ts Regex table pinned to mautrix-whatsapp v0.26.4
└── 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
**Don't touch the committed `config.json`.** Create `config.local.json` at
the project root once — gitignored, never deployed. The host's Vite dev
server overlays it on top of `/config.json` responses (see
`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the
overlay entirely.
Note the overlay merges bot entries **shallowly** — your local
`experience` object replaces the base one wholesale, so it must carry
`provisioningUrl` and `capabilities` too:
```bash
# one-time: install widget deps
cd apps/widget-whatsapp && npm install
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": [
{ "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
@ -94,8 +124,18 @@ cd apps/widget-whatsapp && npm run dev
cd /home/ubuntu/projects/vojo/cinny && npm start
```
Open `http://localhost:8080/bots/whatsapp`. The host's URL validator
accepts `http://localhost:*` only in dev builds.
Open `http://localhost:8080/bots/whatsapp`. Iframe loads cross-origin
from the widget dev server, HMR works, no proxy. The provisioning calls
go straight to the prod bridge API (CORS `*` + per-request bearer auth),
acting on whatever account you're signed in with — same trust model as
the dev client talking to the prod homeserver.
`http://localhost:*` URLs are accepted by the host's URL validator only
in dev builds (`import.meta.env.DEV` branch in
`src/app/features/bots/catalog.ts`); production builds drop the branch
via Vite's dead-code elimination, AND production-only enforces an origin
allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even
if config.json is poisoned.
## Build
@ -103,35 +143,51 @@ accepts `http://localhost:*` only in dev builds.
npm run build
```
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` into
`~/vojo/widgets/whatsapp/` on the production host. The VSCode task
`Deploy widgets` already includes the third subshell — running it from
the host root pushes all three widgets in sequence.
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*`
into `~/vojo/widgets/whatsapp/` on the production host (Caddy serves
this via the `widgets.vojo.chat` block) — the VSCode task
`Deploy widgets` does all three widgets.
## Capacitor (Android)
## Server-side requirements
`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing
TG / Discord widgets — no extra entry needed for WhatsApp.
1. The widget static files at `widgets.vojo.chat/whatsapp/` (Caddy
`handle_path /whatsapp/*` block).
2. The bridge provisioning API exposed at the URL configured in
config.json `experience.provisioningUrl`. Caddy block inside the
`vojo.chat` site, next to the telegram one:
## 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
`handle_path /whatsapp/* { … }` block alongside `/telegram/*` and
`/discord/*`. Then `mkdir -p ~/vojo/widgets/whatsapp` on the server, run
the deploy task, and verify with
`curl -I https://widgets.vojo.chat/whatsapp/index.html`.
Path-scoped on purpose: the same bridge listener serves the appservice
transaction endpoints (`/_matrix/app/*`), which must stay internal.
3. `provisioning.shared_secret` in the bridge config must NOT be
`disable` (a ≥16-char secret enables the API; the widget never sees
the secret — it authenticates with per-user OpenID tokens).
## Source-of-truth pointers
## Updating the production /config.json
- mautrix-whatsapp connector: <https://github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go>
- mautrix-whatsapp connector (post-login session events):
<https://github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go>
- whatsmeow QR format: <https://github.com/tulir/whatsmeow/blob/main/pair.go> (`makeQRData`)
- whatsmeow pairing-code: <https://github.com/tulir/whatsmeow/blob/main/pair-code.go> (`PairPhone`)
- bridgev2 commands layer (shared with mautrix-telegram):
<https://github.com/mautrix/go/blob/main/bridgev2/commands/login.go>
```json
"experience": {
"type": "matrix-widget",
"url": "https://widgets.vojo.chat/whatsapp/index.html",
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
"capabilities": ["vojo.openid"]
}
```
The dialect file `src/bridge-protocol/dialects/bridgev2_v0264.ts` has
inline upstream pointers per regex; when the bridge image is upgraded,
spot-check those pointers and either confirm the wording is still valid
or drop a sibling dialect file with new regexes.
## Capability contract
The widget requests **no** MSC2762 capabilities — the handshake replies
with only `org.matrix.msc4039.download_file` (avatar thumbnails). The
privileged surfaces are:
- MSC1960 `get_openid` — granted by `BotWidgetDriver.askOpenID` iff
config.json declares `"capabilities": ["vojo.openid"]` for this bot;
- `io.vojo.bot-widget` side-channel verbs `open-external-url` and
`open-matrix-to` (origin-pinned and validated host-side in
`BotWidgetEmbed.onWidgetMessage`).

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.
// Source of truth on the host side:
// Parse the URL params the bot widget host appends when loading
// experience.url. Source of truth on the host side:
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
// Keep this in sync if the host adds params.
@ -11,14 +11,12 @@ export type WidgetBootstrap = {
userId: string;
botId: string;
botMxid: string;
/** Bridge command prefix (e.g. `!wa`). Always non-empty the host
* validator (catalog.ts) defaults missing values to `!tg` and rejects
* malformed overrides. The widget prepends `<commandPrefix> ` to every
* outbound command and form-field value (bridgev2/queue.go:118 strips
* exactly `prefix+" "`). For mautrix-whatsapp the operator must set
* `commandPrefix: "!wa"` in /config.json connector.go ships
* `DefaultCommandPrefix: "!wa"`. */
commandPrefix: string;
/** Base URL of the bridge provisioning HTTP API (bridgev2
* `/_matrix/provision` mount behind the reverse proxy), e.g.
* `https://vojo.chat/_provision/whatsapp`. Empty string when the host
* config hasn't exposed it the App renders a config-required notice
* instead of booting the transport. */
provisioningUrl: string;
theme: 'light' | 'dark';
clientLanguage: string;
};
@ -27,7 +25,7 @@ export type BootstrapResult =
| { ok: true; bootstrap: WidgetBootstrap }
| { 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 => {
const params = new URLSearchParams(search);
@ -46,6 +44,27 @@ export const readBootstrap = (search: string): BootstrapResult => {
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 theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
@ -59,7 +78,7 @@ export const readBootstrap = (search: string): BootstrapResult => {
userId: get('userId'),
botId: get('botId'),
botMxid: get('botMxid'),
commandPrefix: get('commandPrefix'),
provisioningUrl,
theme,
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>`
// enforces every RU key has an EN counterpart at compile time.
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
// that every RU key has an EN counterpart at compile time.
import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = {
'status.unknown': 'Checking status…',
'status.disconnected': 'WhatsApp not linked',
'status.connected': 'WhatsApp linked',
'status.connected-as': 'WhatsApp linked as {handle}',
'status.logging-out': 'Signing out…',
'status.qr-verifying': 'Verifying sign-in…',
'status.pairing-verifying': 'Verifying sign-in…',
// --- Status pill ---------------------------------------------------------
'status.checking': 'Checking status…',
'status.disconnected': 'WhatsApp is not linked',
'status.connected-as': 'Linked as {handle}',
// --- Action cards ----------------------------------------------------------
'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.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.desc': 'Re-check whether WhatsApp is linked',
'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.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.',
'warning.tos-label': 'WhatsApp 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.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.',
@ -37,73 +41,110 @@ export const EN: Record<StringKey, string> = {
'about.github-label': 'The bridge source code is public on GitHub:',
'about.github-url': 'https://github.com/mautrix/whatsapp',
'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.aria-close': 'Close “About this bot”',
'auth-card.phone.title': 'Sign in with a pairing code',
'about.aria-close': 'Close “About the bot”',
// --- Phone form (pairing-code flow) -----------------------------------------
'auth-card.phone.title': 'Sign in by phone number',
'auth-card.phone.label': 'Phone number',
'auth-card.phone.placeholder': '+15551234567',
'auth-card.phone.placeholder': '+79991234567',
'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.',
'auth-card.phone.submit': 'Get code',
'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 the code',
'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.hint':
'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':
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
'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.aria': 'WhatsApp sign-in code. Enter it in the app on your phone.',
'auth-card.pairing-code.countdown': '{minutes}:{seconds} left to enter the code',
'auth-card.pairing-code.expired': 'The sign-in window expired. Press “Cancel” and try again.',
'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-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.qr.title': 'QR code sign-in',
'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 the sign-in on your phone.',
'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.preparing': 'Preparing QR code…',
'auth-card.qr.aria': 'QR code for WhatsApp sign-in. Scan it with your phone.',
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
'auth-card.qr.preparing': 'Preparing the QR code…',
'auth-card.qr.aria': 'QR code for signing in to WhatsApp. Scan it with your phone.',
'auth-card.qr.countdown': '{minutes}:{seconds} left to scan',
'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-2': 'Go to Settings → Linked devices.',
'auth-card.qr.step-3': 'Tap Link a device and scan the QR code.',
'auth-card.cancel': 'Cancel',
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
'auth-error.login-failed': 'Sign-in failed: {reason}',
'auth-error.invalid-value': 'Value not accepted: {reason}',
'auth-error.submit-failed': 'WhatsApp refused the input: {reason}',
'auth-error.start-failed': 'Failed to start sign-in: {reason}',
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
'auth-error.login-in-progress':
'The bot already has another sign-in flow open. Click Cancel and retry.',
'auth-error.max-logins': 'Login limit reached ({limit}). Sign out of an existing account first.',
'auth-error.unknown-command':
'The bot does not recognise this command — check the prefix in config.json.',
'auth-error.external-logout.another-device':
'WhatsApp unlinked this device from another device. Sign in again.',
'auth-error.external-logout.phone-logged-out':
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
'card.logout.name': 'Sign out of WhatsApp',
'card.logout.desc': 'End the session for this account',
'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel',
'card.logout.gated': 'Session identifier still loading — give it a moment.',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.',
'diag.checking-status': 'Checking connection status…',
'diag.send-failed': 'send failed: {message}',
'diag.history-marker': '─── history ───',
'diag.history-unavailable': 'Could not read history — re-checking status.',
'diag.qr-issued': 'QR code refreshed.',
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
'diag.pairing-code-issued': 'Pairing code issued.',
'diag.connection-warning': '{text}',
'diag.external-logout': 'WhatsApp dropped the session — sign-in needed.',
'bootstrap.failed': 'Widget failed to start',
'bootstrap.missing-params': 'Missing required URL params: {names}.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
'auth-card.qr.step-3': 'Tap “Link a device” and scan this QR code.',
// --- Global errors / notices ---------------------------------------------
'error.network': 'No connection to the server. Check your internet and try again.',
'error.auth':
'Could not verify your account with the bridge. Reload the page; if that does not help, try again later.',
'error.not-logged-in':
'The WhatsApp session is no longer active. Go back and link the account again.',
'error.openid-blocked':
'The host did not grant the widget sign-in permission — the bot in config.json is missing the vojo.openid capability.',
'error.rate-limited': 'WhatsApp asks to wait: too many code requests. Try again later.',
'error.phone-invalid':
'WhatsApp rejected this number. Enter it in international format and try again.',
'error.multidevice':
'WhatsApp multi-device is not enabled on your phone. Update the WhatsApp app and try again.',
'error.client-outdated':
'The bridge is outdated and WhatsApp refuses its connection. Tell the Vojo administrator.',
'error.pair-failed': 'WhatsApp did not confirm the pairing. Start the sign-in over.',
'error.login-timeout': 'The sign-in window expired. Start over.',
'error.login-restart': 'The sign-in session was lost. Start over.',
'error.too-many-logins': 'Linked-account limit reached. Sign out of the current one first.',
'error.generic': 'Something went wrong: {reason}',
'notice.login-success': 'WhatsApp is linked! Chats will appear in the list within a minute.',
'notice.logged-out': 'WhatsApp session ended.',
// --- Contacts ------------------------------------------------------------
'card.contacts.name': 'Contacts',
'card.contacts.desc': 'Your WhatsApp address book: search by name or number',
'contacts.back': 'Back',
'contacts.search-placeholder': 'Name or +number…',
'contacts.hint':
'These are the contacts from your WhatsApp address book. Pick who to start a chat with in Vojo — the rest are not going anywhere.',
'contacts.loading': 'Loading contacts…',
'contacts.error': 'Could not load contacts.',
'contacts.retry': 'Retry',
'contacts.empty': 'Your WhatsApp address book is empty so far.',
'contacts.empty-filtered': 'Nobody found with that name.',
'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
// match — any `en` variant). Bootstrap forwards `clientLanguage` from
// the host; main.tsx can also call `createT()` without args before
// bootstrap completes (falls back to navigator.language, then RU).
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
// can also call `createT()` without args before bootstrap completes (falls back
// to navigator.language, then RU).
import { RU, type StringKey } from './ru';
import { EN } from './en';

View file

@ -4,77 +4,61 @@
// 2. add the same key + EN value in `en.ts`,
// 3. consume via `t('key', { var: 'x' })` in components.
// 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 = {
// --- Inline section status ---------------------------------------------
'status.unknown': 'Проверка статуса…',
// --- Status pill ---------------------------------------------------------
'status.checking': 'Проверка статуса…',
'status.disconnected': 'WhatsApp не привязан',
'status.connected': 'WhatsApp привязан',
'status.connected-as': 'WhatsApp привязан как {handle}',
'status.logging-out': 'Завершение сеанса…',
// QR-вход: после успешного скана мост стирает QR и переходит к
// подтверждению линка. Это короткий промежуточный pill.
'status.qr-verifying': 'Проверяем вход…',
// Pairing-code вход: после ввода кода в приложении ждём, пока WhatsApp
// подтвердит линк. По времени совпадает с qr-verifying — секунды.
'status.pairing-verifying': 'Проверяем вход…',
// --- Section headers ---------------------------------------------------
'status.connected-as': 'Привязан как {handle}',
// --- Action cards ----------------------------------------------------------
// User flow по сути такой же, как в Telegram: сабмит номера → код. Отличие:
// в TG код вводится в виджет, в WA — в само приложение WhatsApp. Имя кнопки
// одинаковое для consistency между виджетами.
'card.login.name': 'Войти по номеру',
'card.login.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
'card.login-qr.name': 'Войти по QR-коду',
'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.desc': 'Перепроверить, привязан ли WhatsApp',
'card.refresh.in-flight': 'Проверяю…',
// --- About panel -------------------------------------------------------
// WhatsApp-only Meta-ToS risk disclosure is folded into the About
// modal as an amber callout at the top of the body. The AboutCard
// itself carries `command-card warn` (amber border + amber name)
// and a triangle warning glyph in the lead slot — instead of the
// info-circle TG / Discord use — so the «риски» half of the hybrid
// description («о работе и рисках») is visible at a glance before
// the user opens the modal. TG / Discord get 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). The amber
// block keeps the unique WhatsApp framing without claiming anything
// about TG / Discord by comparison.
'card.logout.name': 'Выйти из WhatsApp',
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена',
// --- About panel -----------------------------------------------------------
// WhatsApp-only Meta-ToS risk disclosure is folded into the About modal as
// an amber callout at the top of the body. The About card itself carries
// `command-card warn` (amber border + amber name) and a triangle warning
// glyph in the lead slot — instead of the info-circle TG / Discord use —
// so the «риски» half of the hybrid description («о работе и рисках») is
// visible at a glance before the user opens the modal. TG / Discord get
// 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
// section «Harm To WhatsApp Or Our Users» forbids «software or
// APIs that function substantially the same as our Services» and
// «accounts for our Services through unauthorized or automated
// means».
// section «Harm To WhatsApp Or Our Users» forbids «software or APIs that
// function substantially the same as our Services» and «accounts for our
// Services through unauthorized or automated means».
'card.about.name': 'Как работает WhatsApp-бот',
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
'warning.title': 'Важно знать до подключения WhatsApp',
'warning.body-1':
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
// Источник про запрет в ToS — даём юзеру возможность дойти до
// оригинала самому, не доверять нам на слово. Кликается потому что
// host-side iframe sandbox получил allow-popups (см.
// src/app/features/bots/BotWidgetEmbed.ts).
// Источник про запрет в ToS — даём юзеру возможность дойти до оригинала
// самому, не доверять нам на слово. Кликается потому что host-side iframe
// sandbox получил allow-popups (см. src/app/features/bots/BotWidgetEmbed.ts).
'warning.tos-label': 'Условия использования WhatsApp:',
'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.body-1':
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',
'about.body-2':
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».',
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязка устройства», либо ввести 8-символьный код через «Настройки → Связанные устройства → Связать по номеру телефона».',
'about.body-3':
'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.',
'about.github-label': 'Исходный код моста открыт на GitHub:',
@ -83,8 +67,9 @@ export const RU = {
'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».',
'about.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.placeholder': '+79991234567',
// Подсказка, объясняющая что произойдёт после сабмита: мост создаст
@ -95,96 +80,104 @@ export const RU = {
'auth-card.phone.submit': 'Получить код',
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
// --- Pairing-code form -------------------------------------------------
// --- Pairing-code panel --------------------------------------------------
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
'auth-card.pairing-code.hint':
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».',
'auth-card.pairing-code.preparing': 'Готовим код…',
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Связать по номеру телефона».',
'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.',
'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}',
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
'auth-card.pairing-code.step-3':
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
'Нажмите «Привязка устройства», затем «Связать по номеру телефона».',
'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.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.',
'auth-card.qr.preparing': 'Готовим QR-код…',
'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.',
// Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в
// панели всегда свежий (мост шлёт m.replace edits на каждой ротации),
// отсчёт показывает оставшееся окно ВСЕГО входа.
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в панели
// всегда свежий (long-poll приносит новый на каждой ротации), отсчёт
// показывает оставшееся окно ВСЕГО входа.
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.',
'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».',
'auth-card.qr.step-3': 'Нажмите «Привязать устройство» и отсканируйте QR-код.',
// --- Shared form chrome ------------------------------------------------
'auth-card.cancel': 'Отмена',
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
// --- Inline errors -----------------------------------------------------
// login_failed reasons — мы сохраняем верхатимный текст ошибки от
// upstream. Это даёт юзеру максимально точную диагностику без перевода,
// которое может разъехаться с реальной причиной. Шаблон обёрнут.
'auth-error.login-failed': 'Не удалось войти: {reason}',
'auth-error.invalid-value': 'Значение не принято: {reason}',
'auth-error.submit-failed': 'WhatsApp не принял ввод: {reason}',
'auth-error.start-failed': 'Не удалось начать вход: {reason}',
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
'auth-error.login-in-progress':
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.',
'auth-error.max-logins':
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.',
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
// External-logout варианты — три причины, у каждой своя UX-формулировка.
// «another_device» — другой связанный девайс отвязал нас (например, юзер
// отвязал bridge с другого ноутбука). «phone_logged_out» — юзер вышел
// из WhatsApp на самом телефоне, что ломает все связанные устройства.
// «unknown» — fallback, в т.ч. для startup-нотисов «You're not logged
// into WhatsApp».
'auth-error.external-logout.another-device':
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
'auth-error.external-logout.phone-logged-out':
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
// --- Logout ------------------------------------------------------------
'card.logout.name': 'Выйти из WhatsApp',
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена',
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.',
// --- Diagnostics in transcript ----------------------------------------
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
'diag.ready': 'Готов отправлять команды.',
'diag.checking-status': 'Проверяю статус подключения…',
'diag.send-failed': 'ошибка отправки: {message}',
'diag.history-marker': '─── история ───',
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
// raw whatsmeow handshake (включая adv-secret, который IS the login
// token). Сохранять его в DOM-логе виджета означало бы пережить мост-
// редакцию. В логе только нейтральные диагностические строки.
'diag.qr-issued': 'QR-код обновлён.',
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
// Pairing-код — не такой же чувствительный как QR adv-secret (это
// 8-символьный one-time pairing token, действителен ~3 минуты), но
// всё равно по аналогии с QR не дублируем его в transcript — UI и так
// показывает код большим моноширинным текстом. В логе только нейтральная
// диагностика, чтобы trail был последовательный.
'diag.pairing-code-issued': 'Код для входа выдан.',
// Connection warnings от connector handlewhatsapp.go — они не меняют
// state виджета, просто пишутся в transcript verbatim, чтобы юзер
// понимал, что мост борется с подключением.
'diag.connection-warning': '{text}',
// External-logout transcript echo — короткая строка под красным
// баннером.
'diag.external-logout': 'WhatsApp разорвал сессию — нужен повторный вход.',
'auth-card.qr.step-3': 'Нажмите «Привязка устройства» и отсканируйте QR-код.',
// --- Global errors / notices ---------------------------------------------
'error.network': 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.',
'error.auth':
'Не удалось подтвердить ваш аккаунт у моста. Обновите страницу; если не помогает — попробуйте позже.',
'error.not-logged-in':
'Сессия WhatsApp больше не активна. Вернитесь назад и привяжите аккаунт заново.',
'error.openid-blocked':
'Хост не выдал виджету разрешение на вход — в config.json у бота нет capability vojo.openid.',
'error.rate-limited': 'WhatsApp просит подождать: слишком много запросов кода. Попробуйте позже.',
'error.phone-invalid':
'WhatsApp не принял этот номер. Укажите его в международном формате и попробуйте снова.',
'error.multidevice':
'На телефоне не включён мультиустройственный режим WhatsApp. Обновите приложение WhatsApp и попробуйте снова.',
'error.client-outdated':
'Мост устарел, и WhatsApp отказывает ему в подключении. Сообщите администратору Vojo.',
'error.pair-failed': 'WhatsApp не подтвердил привязку. Начните вход заново.',
'error.login-timeout': 'Время входа истекло. Начните вход заново.',
'error.login-restart': 'Сессия входа потерялась. Начните вход заново.',
'error.too-many-logins': 'Достигнут лимит привязанных аккаунтов. Сначала выйдите из текущего.',
'error.generic': 'Что-то пошло не так: {reason}',
'notice.login-success': 'WhatsApp привязан! Чаты появятся в списке в течение минуты.',
'notice.logged-out': 'Сеанс WhatsApp завершён.',
// --- Contacts ------------------------------------------------------------
'card.contacts.name': 'Контакты',
'card.contacts.desc': 'Записная книжка WhatsApp: поиск по имени или номеру',
'contacts.back': 'Назад',
'contacts.search-placeholder': 'Имя или +номер…',
'contacts.hint':
'Это контакты вашей записной книжки WhatsApp. Выберите, с кем начать чат в Vojo, — остальные никуда не денутся.',
'contacts.loading': 'Загружаем контакты…',
'contacts.error': 'Не удалось загрузить контакты.',
'contacts.retry': 'Повторить',
'contacts.empty': 'В записной книжке WhatsApp пока пусто.',
'contacts.empty-filtered': 'Никого не нашли с таким именем.',
'contacts.start-chat': 'Начать чат',
'contacts.open-chat': 'Открыть чат',
'contacts.creating': 'Создаём чат…',
'contacts.opening': 'Открываем…',
'contacts.probe-check': 'Проверить {handle} в WhatsApp',
'contacts.probe-checking': 'Проверяем {handle}…',
'contacts.probe-not-found': '{handle} не найден в WhatsApp.',
'contacts.probe-found': 'Есть такой! Можно написать.',
'contacts.probe-self': 'Это ваш собственный номер.',
'contacts.refresh': 'Обновить список',
// --- Account tab -----------------------------------------------------------
'account.state-bad':
'Мост сообщает о проблеме с подключением: {reason}. Попробуйте выйти и привязать WhatsApp заново.',
// TRANSIENT_DISCONNECT — телефон давно не в сети / keepalive потерян
// (connector phoneping.go). Обычно чинится само, перепривязка не нужна.
'account.state-transient':
'Мост временно потерял связь с WhatsApp. Проверьте, что телефон в сети, — обычно подключение восстанавливается само.',
// --- Boot / config ---------------------------------------------------------
'boot.connecting': 'Подключение к мосту…',
'config.missing.title': 'Нужна настройка на сервере',
'config.missing.body':
'Виджет работает через API моста, но его адрес не задан в конфигурации (experience.provisioningUrl в config.json) или не выдано разрешение vojo.openid.',
'error.retry': 'Повторить',
// --- Bootstrap failure -------------------------------------------------
'bootstrap.failed': 'Widget не запустился',
'bootstrap.failed': 'Виджет не запустился',
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
} 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 { App } from './App';
import { createT } from './i18n';
import { WidgetApi, buildCapabilities } from './widget-api';
import { WidgetApi } from './widget-api';
import './styles.css';
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
// full rationale. Default to 'mouse'; the capture-phase pointerdown
// listener flips to 'touch' on the first non-mouse pointerType.
// matchMedia guessing was dropped — every variant
// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
// is mis-reported on at least one shipping device.
// Input-mode detector for hover styling. CSS gates `:hover` and
// `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's
// Android Chromium WebView synthesises `:hover` on the focused element
// after a tap and never clears it until the next interaction elsewhere —
// without the gate, every tap leaves a sticky hover state on the tapped
// 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 => {
document.documentElement.dataset.input = mode;
};
@ -52,19 +66,12 @@ if (!result.ok) {
// through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme;
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
// the `window.addEventListener('message', ...)` listener synchronously,
// so by the time the host's ClientWidgetApi fires its capabilities
// request on iframe `load` we're already listening.
//
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
// runs AFTER React's first commit. On a fresh mount the bundle parse +
// 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));
// Instantiate the WidgetApi BEFORE the first render. The constructor
// attaches the `window.addEventListener('message', ...)` listener
// synchronously, so by the time the host's ClientWidgetApi fires its
// capabilities request on iframe `load` we're already listening. On a
// cached-bundle remount the request can race ahead of any useEffect —
// construction at module-load closes that window.
const api = new WidgetApi(result.bootstrap);
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
// the full SDK because:
// - it's CommonJS and forces ESM interop juggling that we hit on the
// dev fixture in the Telegram widget's M2 phase (esm.sh wrapping made
// WidgetApi unavailable as a constructor);
// - the surface we use is small: capabilities reply, theme_change reply,
// send_event request, read_events request, get_openid request, live
// event delivery via send_event toWidget.
// the full SDK because the surface we use is tiny: the capability
// handshake (we request NO capabilities — the bridge is driven over its
// provisioning HTTP API, not over room events), theme_change pushes,
// MSC1960 OpenID credentials, and two `io.vojo.bot-widget` side-channel
// verbs (open-external-url / open-matrix-to).
//
// Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// (in the host repo). Default request timeout on the host transport is
// 10 s — keep that in mind for bridge-bot replies that take time.
// in the host repo.
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 = {
api: 'toWidget';
widgetId: string;
@ -35,8 +18,6 @@ type ToWidgetMessage = {
action: string;
data: Record<string, unknown>;
// 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>;
};
@ -49,16 +30,36 @@ type FromWidgetMessage = {
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 = {
ready: () => void;
liveEvent: (ev: RoomEvent) => void;
themeChange: (name: 'light' | 'dark') => void;
};
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 {
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 }
>();
// 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 isReady = false;
public constructor(
private readonly bootstrap: WidgetBootstrap,
private readonly capabilities: Capability[]
) {
public constructor(private readonly bootstrap: WidgetBootstrap) {
window.addEventListener('message', this.onMessage);
}
@ -82,6 +88,11 @@ export class WidgetApi {
window.removeEventListener('message', this.onMessage);
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
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 {
@ -90,83 +101,82 @@ export class WidgetApi {
// `ready` is a one-shot lifecycle signal. If the handshake completed
// before this listener attached (cached-bundle race: host fires the
// capabilities request on iframe `load`, the WidgetApi catches and
// resolves it during script init, then React's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously so
// App.tsx still flips `handshakeOk` and fires `list-logins`.
// resolves it during script init, then Preact's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously.
if (event === 'ready' && this.isReady) {
(listener as () => void)();
}
}
public sendText(body: string): Promise<{ event_id: string }> {
return this.fromWidget('send_event', {
type: 'm.room.message',
content: { msgtype: 'm.text', body },
}) as Promise<{ event_id: string }>;
// MSC1960: ask the host for OpenID credentials proving our Matrix
// identity. The provisioning client exchanges them with the bridge
// (`Authorization: Bearer openid:<token>`); they are NOT a Matrix access
// token and grant no homeserver power. Rejects when the host driver
// 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
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
// matrix-widget-api's `fromWidget` so it doesn't route through
// ClientWidgetApi's request/response machinery.
//
// Why this exists: cross-origin iframes inside Capacitor's Android
// 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.
// ClientWidgetApi's request/response machinery. Needed because
// cross-origin iframes inside Capacitor's Android WebView silently drop
// `<a target="_blank">` clicks.
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(
{
api: 'io.vojo.bot-widget',
action: 'open-external-url',
data: { url },
},
{ api: 'io.vojo.bot-widget', action, data },
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>(
event: K,
...args: Parameters<WidgetApiEvents[K]>
@ -190,11 +200,10 @@ export class WidgetApi {
if (ev.origin !== this.bootstrap.parentOrigin) return;
// Source-window guard: every legit widget API message comes from the
// host window that embedded our iframe — i.e. window.parent. A foreign
// tab/frame on the same origin (think browser extension content
// script, popup, or sibling iframe) could otherwise post a forged
// message that passes the origin check. We only accept messages
// whose `source` is literally `window.parent`. The `widgetId` check
// a few lines down is a soft filter; this is the hard one.
// tab/frame on the same origin (browser extension content script,
// popup, sibling iframe) could otherwise post a forged message that
// passes the origin check. The `widgetId` check below is a soft
// filter; this is the hard one.
if (ev.source !== window.parent) return;
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return;
@ -230,7 +239,11 @@ export class WidgetApi {
if (!msg.requestId || !msg.action) return;
switch (msg.action) {
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;
}
case 'notify_capabilities': {
@ -242,7 +255,7 @@ export class WidgetApi {
return;
}
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;
}
case 'theme_change': {
@ -252,27 +265,19 @@ export class WidgetApi {
this.replyTo(msg, {});
return;
}
case 'send_event': {
// Live event push from host. Forward `m.room.message` (carries the
// bot's notices / errors / `m.image` QR-login broadcasts AND the
// pairing-code text) AND `m.room.redaction` (post-scan QR cleanup,
// see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State
// events (m.room.member) also arrive on this channel — we still
// ignore them here.
const data = msg.data as Partial<RoomEvent> | undefined;
if (
data &&
data.event_id &&
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
) {
this.emit('liveEvent', data as RoomEvent);
case 'openid_credentials': {
// Phase-2 MSC1960 delivery after a `state: "request"` reply.
const originalId = msg.data?.original_request_id;
if (typeof originalId === 'string') {
const waiter = this.pendingOpenId.get(originalId);
if (waiter) {
this.pendingOpenId.delete(originalId);
window.clearTimeout(waiter.timer);
const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null;
if (creds) waiter.resolve(creds);
else waiter.reject(new Error('OpenID request blocked by host'));
}
this.replyTo(msg, {});
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;
}
@ -285,10 +290,10 @@ export class WidgetApi {
private fromWidget(
action: string,
data: Record<string, unknown>
data: Record<string, unknown>,
requestId = this.nextRequestId()
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const requestId = this.nextRequestId();
this.pending.set(requestId, { resolve, reject });
this.postToHost({
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": {
"type": "matrix-widget",
"url": "https://widgets.vojo.chat/whatsapp/index.html",
"commandPrefix": "!wa"
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
"capabilities": ["vojo.openid"]
}
}
],