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:
parent
5c67c4d78e
commit
0095df358c
21 changed files with 3715 additions and 4164 deletions
4
apps/widget-whatsapp/.gitignore
vendored
Normal file
4
apps/widget-whatsapp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.local
|
||||||
|
|
@ -1,84 +1,114 @@
|
||||||
# @vojo/widget-whatsapp
|
# @vojo/widget-whatsapp
|
||||||
|
|
||||||
Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
|
Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp`
|
||||||
in the Vojo client. Drives the mautrix-whatsapp bridge bot
|
in the Vojo client.
|
||||||
(`@whatsappbot:vojo.chat`) by sending bridgev2 commands in the control DM
|
|
||||||
and rendering the bot's text replies into a typed login flow.
|
|
||||||
|
|
||||||
This is **not** a WhatsApp client — Vojo continues using the Matrix room
|
This is **not** a WhatsApp client. It's a control panel for the
|
||||||
the bridge writes to. The widget is a panel that handles authentication
|
mautrix-whatsapp bridge that talks to the bridge's **provisioning HTTP
|
||||||
(QR scan or pairing code) and surfaces session status.
|
API** (bridgev2 `/_matrix/provision/v3/*`, exposed by Caddy at
|
||||||
|
`https://vojo.chat/_provision/whatsapp`). It links the account (QR scan
|
||||||
|
or an 8-character pairing code typed into the WhatsApp app), shows the
|
||||||
|
linked account, lists WhatsApp contacts, resolves +phone numbers, and
|
||||||
|
creates DM portals on demand.
|
||||||
|
|
||||||
|
Auth: the widget requests MSC1960 OpenID credentials from the host
|
||||||
|
(`get_openid` over the widget API; granted by `BotWidgetDriver.askOpenID`
|
||||||
|
when config.json opts the bot into the `vojo.openid` capability) and
|
||||||
|
sends them to the bridge as `Authorization: Bearer openid:<token>`. The
|
||||||
|
OpenID token only proves identity — it is not a Matrix access token.
|
||||||
|
|
||||||
|
There is no bot text-command transport and no reply parsing: the legacy
|
||||||
|
`!wa`-command dialect (`bridge-protocol/`) was deleted when the bridge
|
||||||
|
API became reachable. The bot control DM still exists (BotShell needs a
|
||||||
|
room and the «Show chat» fallback), the widget just doesn't read or
|
||||||
|
write it — it requests **zero** MSC2762 capabilities.
|
||||||
|
|
||||||
|
## WhatsApp-specific contract notes
|
||||||
|
|
||||||
|
Extracted from mautrix-whatsapp v0.2604.0 (`pkg/connector/login.go`,
|
||||||
|
`startchat.go`) + mautrix-go v0.27.0 (`bridgev2/matrix/provisioning.go`):
|
||||||
|
|
||||||
|
- Login flows: `qr` and `phone`. The phone flow's submit answers with a
|
||||||
|
`display_and_wait` step of type **`code`** — an `XXXX-XXXX` pairing
|
||||||
|
code the user types into the WhatsApp app (NOT an SMS OTP entered into
|
||||||
|
the widget like Telegram's flow).
|
||||||
|
- The login window is ~160 s: whatsmeow issues ~6 QR tokens (60 s + 5×20 s)
|
||||||
|
and closes the socket when they run out; the pairing code lives in the
|
||||||
|
same window.
|
||||||
|
- There are **no** `.incorrect` retry steps: every rejected input is a
|
||||||
|
`FI.MAU.WHATSAPP.*` RespError that deletes the login process
|
||||||
|
server-side. The phone form transparently starts a fresh process on
|
||||||
|
resubmit.
|
||||||
|
- Login-liveness semantics differ by bridge generation. mautrix ≤ v0.28.0:
|
||||||
|
the login process dies with the poll's request context (and there is
|
||||||
|
**no** `/v3/login/cancel` route — cancelling works by aborting the
|
||||||
|
long-poll). mautrix ≥ v0.28.1 (`provisioninglogin.go`): the process
|
||||||
|
lives server-side with a 30-minute TTL, dropped polls reattach, and the
|
||||||
|
cancel route exists. The widget's wait loop is written for both: it
|
||||||
|
retries transport-shaped poll failures with backoff (Android freezes
|
||||||
|
the backgrounded WebView while the user enters the pairing code in
|
||||||
|
WhatsApp), wakes on return to foreground, and disambiguates a late
|
||||||
|
404 via whoami (the login may have COMPLETED while frozen).
|
||||||
|
- Identifiers are phone numbers only (`tel:+…`); no usernames. Contact
|
||||||
|
ids are bare phone digits (or `lid-`/`bot-` prefixed for
|
||||||
|
hidden-number/bot peers).
|
||||||
|
|
||||||
|
The About card and modal carry the **Meta-ToS risk disclosure**
|
||||||
|
(`warning.*` keys, amber warn styling, triangle icon) — WhatsApp's terms
|
||||||
|
of service forbid third-party clients and Meta may ban accounts for it.
|
||||||
|
This copy is a deliberate product/legal requirement: keep it intact when
|
||||||
|
restyling.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── bootstrap.ts Parse URL params the host appends (mirrors BotWidgetEmbed.ts)
|
├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts)
|
||||||
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK)
|
├── widget-api.ts Inline matrix-widget-api postMessage transport: handshake,
|
||||||
├── App.tsx UI: login forms, QR / pairing-code panels, transcript pane
|
│ theme, MSC1960 get_openid, io.vojo.bot-widget verbs
|
||||||
|
├── 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
|
├── main.tsx Entry: init bootstrap, render App or diagnostic
|
||||||
├── styles.css Theme-aware CSS (Vojo Dawn palette)
|
└── styles.css Telegram-widget stylesheet verbatim + WhatsApp-only
|
||||||
├── state.ts Login state machine + hydrate-from-timeline
|
additions (warn card, ToS callout, pairing-code plate)
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Login flows
|
|
||||||
|
|
||||||
WhatsApp's mautrix bridge ships TWO login flows (see
|
|
||||||
`pkg/connector/login.go::GetLoginFlows`):
|
|
||||||
|
|
||||||
1. **QR** (`!wa login qr`) — bridge emits a rotating `m.image` whose body
|
|
||||||
is the raw whatsmeow handshake payload (`<ref>,<noise>,<identity>,<adv>`,
|
|
||||||
four base64 fields). The widget renders it as a QR matrix client-side.
|
|
||||||
Whatsmeow `qrIntervals = [60s, 20s, 20s, 20s, 20s, 20s]` — first QR
|
|
||||||
lasts 60 seconds, then five rotations of 20 seconds each. Total active
|
|
||||||
window: 2 minutes 40 seconds. Each rotation arrives as an `m.replace`
|
|
||||||
edit of the original event; the state machine matches on the original
|
|
||||||
id and repaints the matrix.
|
|
||||||
|
|
||||||
2. **Pairing code** (`!wa login phone`) — alternative for users whose
|
|
||||||
camera doesn't work or who prefer typing. The user enters a phone
|
|
||||||
number; the bridge replies with two notices:
|
|
||||||
- `Input the pairing code in the WhatsApp mobile app to log in`
|
|
||||||
- The 8-character code itself (`XXXX-XXXX`, custom base32 alphabet).
|
|
||||||
The widget renders the code prominently and the user enters it in
|
|
||||||
WhatsApp → Settings → Linked devices → Link with phone number.
|
|
||||||
|
|
||||||
There is **no 2FA cloud-password step** — multidevice handshake is
|
|
||||||
single-factor. The state machine has no `awaiting_password` arm.
|
|
||||||
|
|
||||||
## Capability contract
|
|
||||||
|
|
||||||
The widget requests EXACTLY this set (matches the host's
|
|
||||||
`BotWidgetDriver.getBotWidgetCapabilities`):
|
|
||||||
|
|
||||||
```
|
|
||||||
org.matrix.msc2762.timeline:<roomId>
|
|
||||||
org.matrix.msc2762.send.event:m.room.message#m.text
|
|
||||||
org.matrix.msc2762.receive.event:m.room.message#m.text
|
|
||||||
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
|
||||||
org.matrix.msc2762.receive.event:m.room.message#m.image
|
|
||||||
org.matrix.msc2762.receive.event:m.room.redaction
|
|
||||||
org.matrix.msc2762.receive.state_event:m.room.member
|
|
||||||
```
|
|
||||||
|
|
||||||
Anything else is silently dropped by the host. The capability set is
|
|
||||||
identical to the Telegram widget's M13 expansion — the host driver
|
|
||||||
already supports `m.image` + `m.room.redaction`.
|
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
|
**Don't touch the committed `config.json`.** Create `config.local.json` at
|
||||||
|
the project root once — gitignored, never deployed. The host's Vite dev
|
||||||
|
server overlays it on top of `/config.json` responses (see
|
||||||
|
`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the
|
||||||
|
overlay entirely.
|
||||||
|
|
||||||
|
Note the overlay merges bot entries **shallowly** — your local
|
||||||
|
`experience` object replaces the base one wholesale, so it must carry
|
||||||
|
`provisioningUrl` and `capabilities` too:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# one-time: install widget deps
|
||||||
cd apps/widget-whatsapp && npm install
|
cd apps/widget-whatsapp && npm install
|
||||||
cd /home/ubuntu/projects/vojo/cinny && cat > config.local.json <<'JSON'
|
|
||||||
|
# one-time: create config.local.json (gitignored) at the project root
|
||||||
|
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
|
||||||
{
|
{
|
||||||
"bots": [
|
"bots": [
|
||||||
{ "id": "whatsapp", "experience": { "type": "matrix-widget", "url": "http://localhost:8083/" } }
|
{
|
||||||
|
"id": "whatsapp",
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "http://localhost:8083/",
|
||||||
|
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
|
||||||
|
"capabilities": ["vojo.openid"]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
@ -94,8 +124,18 @@ cd apps/widget-whatsapp && npm run dev
|
||||||
cd /home/ubuntu/projects/vojo/cinny && npm start
|
cd /home/ubuntu/projects/vojo/cinny && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:8080/bots/whatsapp`. The host's URL validator
|
Open `http://localhost:8080/bots/whatsapp`. Iframe loads cross-origin
|
||||||
accepts `http://localhost:*` only in dev builds.
|
from the widget dev server, HMR works, no proxy. The provisioning calls
|
||||||
|
go straight to the prod bridge API (CORS `*` + per-request bearer auth),
|
||||||
|
acting on whatever account you're signed in with — same trust model as
|
||||||
|
the dev client talking to the prod homeserver.
|
||||||
|
|
||||||
|
`http://localhost:*` URLs are accepted by the host's URL validator only
|
||||||
|
in dev builds (`import.meta.env.DEV` branch in
|
||||||
|
`src/app/features/bots/catalog.ts`); production builds drop the branch
|
||||||
|
via Vite's dead-code elimination, AND production-only enforces an origin
|
||||||
|
allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even
|
||||||
|
if config.json is poisoned.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|
@ -103,35 +143,51 @@ accepts `http://localhost:*` only in dev builds.
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` into
|
Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*`
|
||||||
`~/vojo/widgets/whatsapp/` on the production host. The VSCode task
|
into `~/vojo/widgets/whatsapp/` on the production host (Caddy serves
|
||||||
`Deploy widgets` already includes the third subshell — running it from
|
this via the `widgets.vojo.chat` block) — the VSCode task
|
||||||
the host root pushes all three widgets in sequence.
|
`Deploy widgets` does all three widgets.
|
||||||
|
|
||||||
## Capacitor (Android)
|
## Server-side requirements
|
||||||
|
|
||||||
`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing
|
1. The widget static files at `widgets.vojo.chat/whatsapp/` (Caddy
|
||||||
TG / Discord widgets — no extra entry needed for WhatsApp.
|
`handle_path /whatsapp/*` block).
|
||||||
|
2. The bridge provisioning API exposed at the URL configured in
|
||||||
|
config.json `experience.provisioningUrl`. Caddy block inside the
|
||||||
|
`vojo.chat` site, next to the telegram one:
|
||||||
|
|
||||||
## Hosting (server-side)
|
```
|
||||||
|
handle /_provision/whatsapp/* {
|
||||||
|
uri replace /_provision/whatsapp /_matrix/provision
|
||||||
|
reverse_proxy whatsapp-bridge:29318 # host:port from appservice.address
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Same Caddy `widgets.vojo.chat` block as the other widgets — add a third
|
Path-scoped on purpose: the same bridge listener serves the appservice
|
||||||
`handle_path /whatsapp/* { … }` block alongside `/telegram/*` and
|
transaction endpoints (`/_matrix/app/*`), which must stay internal.
|
||||||
`/discord/*`. Then `mkdir -p ~/vojo/widgets/whatsapp` on the server, run
|
3. `provisioning.shared_secret` in the bridge config must NOT be
|
||||||
the deploy task, and verify with
|
`disable` (a ≥16-char secret enables the API; the widget never sees
|
||||||
`curl -I https://widgets.vojo.chat/whatsapp/index.html`.
|
the secret — it authenticates with per-user OpenID tokens).
|
||||||
|
|
||||||
## Source-of-truth pointers
|
## Updating the production /config.json
|
||||||
|
|
||||||
- mautrix-whatsapp connector: <https://github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go>
|
```json
|
||||||
- mautrix-whatsapp connector (post-login session events):
|
"experience": {
|
||||||
<https://github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go>
|
"type": "matrix-widget",
|
||||||
- whatsmeow QR format: <https://github.com/tulir/whatsmeow/blob/main/pair.go> (`makeQRData`)
|
"url": "https://widgets.vojo.chat/whatsapp/index.html",
|
||||||
- whatsmeow pairing-code: <https://github.com/tulir/whatsmeow/blob/main/pair-code.go> (`PairPhone`)
|
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
|
||||||
- bridgev2 commands layer (shared with mautrix-telegram):
|
"capabilities": ["vojo.openid"]
|
||||||
<https://github.com/mautrix/go/blob/main/bridgev2/commands/login.go>
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The dialect file `src/bridge-protocol/dialects/bridgev2_v0264.ts` has
|
## Capability contract
|
||||||
inline upstream pointers per regex; when the bridge image is upgraded,
|
|
||||||
spot-check those pointers and either confirm the wording is still valid
|
The widget requests **no** MSC2762 capabilities — the handshake replies
|
||||||
or drop a sibling dialect file with new regexes.
|
with only `org.matrix.msc4039.download_file` (avatar thumbnails). The
|
||||||
|
privileged surfaces are:
|
||||||
|
|
||||||
|
- MSC1960 `get_openid` — granted by `BotWidgetDriver.askOpenID` iff
|
||||||
|
config.json declares `"capabilities": ["vojo.openid"]` for this bot;
|
||||||
|
- `io.vojo.bot-widget` side-channel verbs `open-external-url` and
|
||||||
|
`open-matrix-to` (origin-pinned and validated host-side in
|
||||||
|
`BotWidgetEmbed.onWidgetMessage`).
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
121
apps/widget-whatsapp/src/avatars.ts
Normal file
121
apps/widget-whatsapp/src/avatars.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Parse the URL params the host appends when loading experience.url.
|
// Parse the URL params the bot widget host appends when loading
|
||||||
// Source of truth on the host side:
|
// experience.url. Source of truth on the host side:
|
||||||
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
|
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
|
||||||
// Keep this in sync if the host adds params.
|
// Keep this in sync if the host adds params.
|
||||||
|
|
||||||
|
|
@ -11,14 +11,12 @@ export type WidgetBootstrap = {
|
||||||
userId: string;
|
userId: string;
|
||||||
botId: string;
|
botId: string;
|
||||||
botMxid: string;
|
botMxid: string;
|
||||||
/** Bridge command prefix (e.g. `!wa`). Always non-empty — the host
|
/** Base URL of the bridge provisioning HTTP API (bridgev2
|
||||||
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
* `/_matrix/provision` mount behind the reverse proxy), e.g.
|
||||||
* malformed overrides. The widget prepends `<commandPrefix> ` to every
|
* `https://vojo.chat/_provision/whatsapp`. Empty string when the host
|
||||||
* outbound command and form-field value (bridgev2/queue.go:118 strips
|
* config hasn't exposed it — the App renders a config-required notice
|
||||||
* exactly `prefix+" "`). For mautrix-whatsapp the operator must set
|
* instead of booting the transport. */
|
||||||
* `commandPrefix: "!wa"` in /config.json — connector.go ships
|
provisioningUrl: string;
|
||||||
* `DefaultCommandPrefix: "!wa"`. */
|
|
||||||
commandPrefix: string;
|
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
clientLanguage: string;
|
clientLanguage: string;
|
||||||
};
|
};
|
||||||
|
|
@ -27,7 +25,7 @@ export type BootstrapResult =
|
||||||
| { ok: true; bootstrap: WidgetBootstrap }
|
| { ok: true; bootstrap: WidgetBootstrap }
|
||||||
| { ok: false; missing: string[] };
|
| { ok: false; missing: string[] };
|
||||||
|
|
||||||
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
|
||||||
|
|
||||||
export const readBootstrap = (search: string): BootstrapResult => {
|
export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
|
|
@ -46,6 +44,27 @@ export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
return { ok: false, missing: ['parentUrl'] };
|
return { ok: false, missing: ['parentUrl'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The host validator (catalog.ts normalizeProvisioningUrl) already
|
||||||
|
// enforces https + no embedded credentials; re-parse defensively anyway
|
||||||
|
// because this is the widget's fetch target. Malformed → '' → the App
|
||||||
|
// shows the config-required notice rather than fetching a garbage URL.
|
||||||
|
let provisioningUrl = '';
|
||||||
|
const rawProvisioning = get('provisioningUrl').trim();
|
||||||
|
if (rawProvisioning) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawProvisioning);
|
||||||
|
if (
|
||||||
|
!parsed.username &&
|
||||||
|
!parsed.password &&
|
||||||
|
(parsed.protocol === 'https:' || (import.meta.env.DEV && parsed.protocol === 'http:'))
|
||||||
|
) {
|
||||||
|
provisioningUrl = parsed.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep '' */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const themeRaw = get('theme');
|
const themeRaw = get('theme');
|
||||||
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
|
@ -59,7 +78,7 @@ export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
userId: get('userId'),
|
userId: get('userId'),
|
||||||
botId: get('botId'),
|
botId: get('botId'),
|
||||||
botMxid: get('botMxid'),
|
botMxid: get('botMxid'),
|
||||||
commandPrefix: get('commandPrefix'),
|
provisioningUrl,
|
||||||
theme,
|
theme,
|
||||||
clientLanguage: get('clientLanguage'),
|
clientLanguage: get('clientLanguage'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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' };
|
|
||||||
408
apps/widget-whatsapp/src/contacts.tsx
Normal file
408
apps/widget-whatsapp/src/contacts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
apps/widget-whatsapp/src/errors.ts
Normal file
86
apps/widget-whatsapp/src/errors.ts
Normal 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) });
|
||||||
|
};
|
||||||
|
|
@ -1,32 +1,36 @@
|
||||||
// English fallback. Mirror the RU key set; `Record<StringKey, string>`
|
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
|
||||||
// enforces every RU key has an EN counterpart at compile time.
|
// that every RU key has an EN counterpart at compile time.
|
||||||
|
|
||||||
import type { StringKey } from './ru';
|
import type { StringKey } from './ru';
|
||||||
|
|
||||||
export const EN: Record<StringKey, string> = {
|
export const EN: Record<StringKey, string> = {
|
||||||
'status.unknown': 'Checking status…',
|
// --- Status pill ---------------------------------------------------------
|
||||||
'status.disconnected': 'WhatsApp not linked',
|
'status.checking': 'Checking status…',
|
||||||
'status.connected': 'WhatsApp linked',
|
'status.disconnected': 'WhatsApp is not linked',
|
||||||
'status.connected-as': 'WhatsApp linked as {handle}',
|
'status.connected-as': 'Linked as {handle}',
|
||||||
'status.logging-out': 'Signing out…',
|
|
||||||
'status.qr-verifying': 'Verifying sign-in…',
|
// --- Action cards ----------------------------------------------------------
|
||||||
'status.pairing-verifying': 'Verifying sign-in…',
|
'card.login.name': 'Sign in by phone number',
|
||||||
|
'card.login.desc': 'Enter your number and get an 8-character code for WhatsApp',
|
||||||
'card.login-qr.name': 'Sign in with QR code',
|
'card.login-qr.name': 'Sign in with QR code',
|
||||||
'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app',
|
'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app',
|
||||||
'card.login-pairing.name': 'Sign in by phone number',
|
|
||||||
'card.login-pairing.desc': 'Enter your number and get an 8-character code for WhatsApp',
|
|
||||||
'card.refresh.aria': 'Refresh status',
|
|
||||||
'card.refresh.label': 'Refresh status',
|
|
||||||
'card.refresh.name': 'Refresh status',
|
'card.refresh.name': 'Refresh status',
|
||||||
'card.refresh.desc': 'Re-check whether WhatsApp is linked',
|
'card.refresh.desc': 'Re-check whether WhatsApp is linked',
|
||||||
'card.refresh.in-flight': 'Checking…',
|
'card.refresh.in-flight': 'Checking…',
|
||||||
|
'card.logout.name': 'Sign out of WhatsApp',
|
||||||
|
'card.logout.desc': 'End the session on this account',
|
||||||
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
|
'card.logout.confirm-no': 'Cancel',
|
||||||
|
|
||||||
|
// --- About panel -----------------------------------------------------------
|
||||||
|
'card.about.name': 'How the WhatsApp bot works',
|
||||||
|
'card.about.desc': 'How it works and the risks — tap to read',
|
||||||
'warning.title': 'Important before linking WhatsApp',
|
'warning.title': 'Important before linking WhatsApp',
|
||||||
'warning.body-1':
|
'warning.body-1':
|
||||||
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApp’s terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
|
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApp’s terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
|
||||||
'warning.tos-label': 'WhatsApp terms of service:',
|
'warning.tos-label': 'WhatsApp terms of service:',
|
||||||
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
||||||
'card.about.name': 'How the WhatsApp bot works',
|
|
||||||
'card.about.desc': 'How it works and the risks — tap to read',
|
|
||||||
'about.title': 'About the WhatsApp bot',
|
'about.title': 'About the WhatsApp bot',
|
||||||
'about.body-1':
|
'about.body-1':
|
||||||
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',
|
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',
|
||||||
|
|
@ -37,73 +41,110 @@ export const EN: Record<StringKey, string> = {
|
||||||
'about.github-label': 'The bridge source code is public on GitHub:',
|
'about.github-label': 'The bridge source code is public on GitHub:',
|
||||||
'about.github-url': 'https://github.com/mautrix/whatsapp',
|
'about.github-url': 'https://github.com/mautrix/whatsapp',
|
||||||
'about.body-4':
|
'about.body-4':
|
||||||
'You can revoke access at any time — either with the “Sign out of WhatsApp” button here, or inside WhatsApp itself under Settings → Linked devices → Log out of all devices.',
|
'You can revoke access at any time — with the “Sign out of WhatsApp” button here, or in WhatsApp itself under Settings → Linked devices → Log out from all devices.',
|
||||||
'about.close': 'Close',
|
'about.close': 'Close',
|
||||||
'about.aria-close': 'Close “About this bot”',
|
'about.aria-close': 'Close “About the bot”',
|
||||||
'auth-card.phone.title': 'Sign in with a pairing code',
|
|
||||||
|
// --- Phone form (pairing-code flow) -----------------------------------------
|
||||||
|
'auth-card.phone.title': 'Sign in by phone number',
|
||||||
'auth-card.phone.label': 'Phone number',
|
'auth-card.phone.label': 'Phone number',
|
||||||
'auth-card.phone.placeholder': '+15551234567',
|
'auth-card.phone.placeholder': '+79991234567',
|
||||||
'auth-card.phone.hint':
|
'auth-card.phone.hint':
|
||||||
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.',
|
'Enter your number with the country code. WhatsApp will then create an 8-character code to enter in the app.',
|
||||||
'auth-card.phone.submit': 'Get code',
|
'auth-card.phone.submit': 'Get the code',
|
||||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
'auth-card.phone.cooldown': 'Retry in {seconds} s',
|
||||||
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
|
'auth-card.phone.invalid': 'The number looks incomplete or mistyped.',
|
||||||
|
|
||||||
|
// --- Pairing-code panel --------------------------------------------------
|
||||||
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
|
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
|
||||||
'auth-card.pairing-code.hint':
|
'auth-card.pairing-code.hint':
|
||||||
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
||||||
'auth-card.pairing-code.preparing': 'Preparing the code…',
|
'auth-card.pairing-code.aria': 'WhatsApp sign-in code. Enter it in the app on your phone.',
|
||||||
'auth-card.pairing-code.aria':
|
'auth-card.pairing-code.countdown': '{minutes}:{seconds} left to enter the code',
|
||||||
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
'auth-card.pairing-code.expired': 'The sign-in window expired. Press “Cancel” and try again.',
|
||||||
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
|
|
||||||
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
|
||||||
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
||||||
'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.',
|
'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.',
|
||||||
'auth-card.pairing-code.step-3': 'Tap Link a device → Link with phone number.',
|
'auth-card.pairing-code.step-3': 'Tap “Link a device → Link with phone number”.',
|
||||||
'auth-card.pairing-code.step-4': 'Enter this code and confirm sign-in on your phone.',
|
'auth-card.pairing-code.step-4': 'Enter this code and confirm the sign-in on your phone.',
|
||||||
'auth-card.qr.title': 'QR code sign-in',
|
'auth-card.pairing-code.copy': 'Copy the code',
|
||||||
|
'auth-card.pairing-code.copied': 'Copied',
|
||||||
|
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
'auth-card.cancel': 'Cancel',
|
||||||
|
'auth-card.waiting-hint': 'The bridge is still thinking… replies can take up to 30 seconds.',
|
||||||
|
|
||||||
|
// --- QR panel ------------------------------------------------------------
|
||||||
|
'auth-card.qr.title': 'Sign in with QR code',
|
||||||
'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.',
|
'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.',
|
||||||
'auth-card.qr.preparing': 'Preparing QR code…',
|
'auth-card.qr.preparing': 'Preparing the QR code…',
|
||||||
'auth-card.qr.aria': 'QR code for WhatsApp sign-in. Scan it with your phone.',
|
'auth-card.qr.aria': 'QR code for signing in to WhatsApp. Scan it with your phone.',
|
||||||
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
|
'auth-card.qr.countdown': '{minutes}:{seconds} left to scan',
|
||||||
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
'auth-card.qr.expired': 'The sign-in window expired. Press “Cancel” and try again.',
|
||||||
'auth-card.qr.step-1': 'Open WhatsApp on your phone.',
|
'auth-card.qr.step-1': 'Open WhatsApp on your phone.',
|
||||||
'auth-card.qr.step-2': 'Go to Settings → Linked devices.',
|
'auth-card.qr.step-2': 'Go to Settings → Linked devices.',
|
||||||
'auth-card.qr.step-3': 'Tap Link a device and scan the QR code.',
|
'auth-card.qr.step-3': 'Tap “Link a device” and scan this QR code.',
|
||||||
'auth-card.cancel': 'Cancel',
|
|
||||||
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
// --- Global errors / notices ---------------------------------------------
|
||||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
'error.network': 'No connection to the server. Check your internet and try again.',
|
||||||
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
'error.auth':
|
||||||
'auth-error.submit-failed': 'WhatsApp refused the input: {reason}',
|
'Could not verify your account with the bridge. Reload the page; if that does not help, try again later.',
|
||||||
'auth-error.start-failed': 'Failed to start sign-in: {reason}',
|
'error.not-logged-in':
|
||||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
'The WhatsApp session is no longer active. Go back and link the account again.',
|
||||||
'auth-error.login-in-progress':
|
'error.openid-blocked':
|
||||||
'The bot already has another sign-in flow open. Click Cancel and retry.',
|
'The host did not grant the widget sign-in permission — the bot in config.json is missing the vojo.openid capability.',
|
||||||
'auth-error.max-logins': 'Login limit reached ({limit}). Sign out of an existing account first.',
|
'error.rate-limited': 'WhatsApp asks to wait: too many code requests. Try again later.',
|
||||||
'auth-error.unknown-command':
|
'error.phone-invalid':
|
||||||
'The bot does not recognise this command — check the prefix in config.json.',
|
'WhatsApp rejected this number. Enter it in international format and try again.',
|
||||||
'auth-error.external-logout.another-device':
|
'error.multidevice':
|
||||||
'WhatsApp unlinked this device from another device. Sign in again.',
|
'WhatsApp multi-device is not enabled on your phone. Update the WhatsApp app and try again.',
|
||||||
'auth-error.external-logout.phone-logged-out':
|
'error.client-outdated':
|
||||||
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
'The bridge is outdated and WhatsApp refuses its connection. Tell the Vojo administrator.',
|
||||||
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
|
'error.pair-failed': 'WhatsApp did not confirm the pairing. Start the sign-in over.',
|
||||||
'card.logout.name': 'Sign out of WhatsApp',
|
'error.login-timeout': 'The sign-in window expired. Start over.',
|
||||||
'card.logout.desc': 'End the session for this account',
|
'error.login-restart': 'The sign-in session was lost. Start over.',
|
||||||
'card.logout.confirm-prompt': 'Sign out for real?',
|
'error.too-many-logins': 'Linked-account limit reached. Sign out of the current one first.',
|
||||||
'card.logout.confirm-yes': 'Sign out',
|
'error.generic': 'Something went wrong: {reason}',
|
||||||
'card.logout.confirm-no': 'Cancel',
|
'notice.login-success': 'WhatsApp is linked! Chats will appear in the list within a minute.',
|
||||||
'card.logout.gated': 'Session identifier still loading — give it a moment.',
|
'notice.logged-out': 'WhatsApp session ended.',
|
||||||
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
|
||||||
'diag.ready': 'Ready to send commands.',
|
// --- Contacts ------------------------------------------------------------
|
||||||
'diag.checking-status': 'Checking connection status…',
|
'card.contacts.name': 'Contacts',
|
||||||
'diag.send-failed': 'send failed: {message}',
|
'card.contacts.desc': 'Your WhatsApp address book: search by name or number',
|
||||||
'diag.history-marker': '─── history ───',
|
'contacts.back': 'Back',
|
||||||
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
'contacts.search-placeholder': 'Name or +number…',
|
||||||
'diag.qr-issued': 'QR code refreshed.',
|
'contacts.hint':
|
||||||
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
'These are the contacts from your WhatsApp address book. Pick who to start a chat with in Vojo — the rest are not going anywhere.',
|
||||||
'diag.pairing-code-issued': 'Pairing code issued.',
|
'contacts.loading': 'Loading contacts…',
|
||||||
'diag.connection-warning': '{text}',
|
'contacts.error': 'Could not load contacts.',
|
||||||
'diag.external-logout': 'WhatsApp dropped the session — sign-in needed.',
|
'contacts.retry': 'Retry',
|
||||||
'bootstrap.failed': 'Widget failed to start',
|
'contacts.empty': 'Your WhatsApp address book is empty so far.',
|
||||||
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
'contacts.empty-filtered': 'Nobody found with that name.',
|
||||||
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
'contacts.start-chat': 'Start chat',
|
||||||
|
'contacts.open-chat': 'Open chat',
|
||||||
|
'contacts.creating': 'Creating chat…',
|
||||||
|
'contacts.opening': 'Opening…',
|
||||||
|
'contacts.probe-check': 'Check {handle} on WhatsApp',
|
||||||
|
'contacts.probe-checking': 'Checking {handle}…',
|
||||||
|
'contacts.probe-not-found': '{handle} was not found on WhatsApp.',
|
||||||
|
'contacts.probe-found': 'Found them! You can start a chat.',
|
||||||
|
'contacts.probe-self': 'That is your own number.',
|
||||||
|
'contacts.refresh': 'Refresh list',
|
||||||
|
|
||||||
|
// --- Account tab -----------------------------------------------------------
|
||||||
|
'account.state-bad':
|
||||||
|
'The bridge reports a connection problem: {reason}. Try signing out and linking WhatsApp again.',
|
||||||
|
'account.state-transient':
|
||||||
|
'The bridge temporarily lost the connection to WhatsApp. Check that your phone is online — it usually recovers on its own.',
|
||||||
|
|
||||||
|
// --- Boot / config ---------------------------------------------------------
|
||||||
|
'boot.connecting': 'Connecting to the bridge…',
|
||||||
|
'config.missing.title': 'Server-side setup required',
|
||||||
|
'config.missing.body':
|
||||||
|
'The widget talks to the bridge API, but its address is not configured (experience.provisioningUrl in config.json) or the vojo.openid permission is missing.',
|
||||||
|
'error.retry': 'Retry',
|
||||||
|
|
||||||
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
|
'bootstrap.failed': 'The widget failed to start',
|
||||||
|
'bootstrap.missing-params': 'Required URL parameters are missing: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at the {route} route.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix
|
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
|
||||||
// match — any `en` variant). Bootstrap forwards `clientLanguage` from
|
// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
|
||||||
// the host; main.tsx can also call `createT()` without args before
|
// can also call `createT()` without args before bootstrap completes (falls back
|
||||||
// bootstrap completes (falls back to navigator.language, then RU).
|
// to navigator.language, then RU).
|
||||||
|
|
||||||
import { RU, type StringKey } from './ru';
|
import { RU, type StringKey } from './ru';
|
||||||
import { EN } from './en';
|
import { EN } from './en';
|
||||||
|
|
|
||||||
|
|
@ -4,77 +4,61 @@
|
||||||
// 2. add the same key + EN value in `en.ts`,
|
// 2. add the same key + EN value in `en.ts`,
|
||||||
// 3. consume via `t('key', { var: 'x' })` in components.
|
// 3. consume via `t('key', { var: 'x' })` in components.
|
||||||
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
||||||
//
|
|
||||||
// The widget no longer renders a hero — that block lives in the host's
|
|
||||||
// BotShellHero. Status is surfaced inline inside the relevant section.
|
|
||||||
|
|
||||||
export const RU = {
|
export const RU = {
|
||||||
// --- Inline section status ---------------------------------------------
|
// --- Status pill ---------------------------------------------------------
|
||||||
'status.unknown': 'Проверка статуса…',
|
'status.checking': 'Проверка статуса…',
|
||||||
'status.disconnected': 'WhatsApp не привязан',
|
'status.disconnected': 'WhatsApp не привязан',
|
||||||
'status.connected': 'WhatsApp привязан',
|
'status.connected-as': 'Привязан как {handle}',
|
||||||
'status.connected-as': 'WhatsApp привязан как {handle}',
|
|
||||||
'status.logging-out': 'Завершение сеанса…',
|
// --- Action cards ----------------------------------------------------------
|
||||||
// QR-вход: после успешного скана мост стирает QR и переходит к
|
// User flow по сути такой же, как в Telegram: сабмит номера → код. Отличие:
|
||||||
// подтверждению линка. Это короткий промежуточный pill.
|
// в TG код вводится в виджет, в WA — в само приложение WhatsApp. Имя кнопки
|
||||||
'status.qr-verifying': 'Проверяем вход…',
|
// одинаковое для consistency между виджетами.
|
||||||
// Pairing-code вход: после ввода кода в приложении ждём, пока WhatsApp
|
'card.login.name': 'Войти по номеру',
|
||||||
// подтвердит линк. По времени совпадает с qr-verifying — секунды.
|
'card.login.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
|
||||||
'status.pairing-verifying': 'Проверяем вход…',
|
|
||||||
// --- Section headers ---------------------------------------------------
|
|
||||||
'card.login-qr.name': 'Войти по QR-коду',
|
'card.login-qr.name': 'Войти по QR-коду',
|
||||||
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp',
|
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp',
|
||||||
// WA-эквивалент TG-шного «Войти по номеру». User flow по сути такой
|
|
||||||
// же, как в Telegram: сабмит номера → бот выдаёт код → код вводится.
|
|
||||||
// Отличие: в TG код вводится в виджет, в WA — в само приложение
|
|
||||||
// WhatsApp. Имя кнопки одинаковое для consistency между виджетами.
|
|
||||||
'card.login-pairing.name': 'Войти по номеру',
|
|
||||||
'card.login-pairing.desc': 'Ввести номер и получить 8-символьный код для WhatsApp',
|
|
||||||
'card.refresh.aria': 'Обновить статус',
|
|
||||||
'card.refresh.label': 'Обновить статус',
|
|
||||||
'card.refresh.name': 'Обновить статус',
|
'card.refresh.name': 'Обновить статус',
|
||||||
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
|
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
|
||||||
'card.refresh.in-flight': 'Проверяю…',
|
'card.refresh.in-flight': 'Проверяю…',
|
||||||
// --- About panel -------------------------------------------------------
|
'card.logout.name': 'Выйти из WhatsApp',
|
||||||
// WhatsApp-only Meta-ToS risk disclosure is folded into the About
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
// modal as an amber callout at the top of the body. The AboutCard
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
// itself carries `command-card warn` (amber border + amber name)
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
// and a triangle warning glyph in the lead slot — instead of the
|
'card.logout.confirm-no': 'Отмена',
|
||||||
// info-circle TG / Discord use — so the «риски» half of the hybrid
|
|
||||||
// description («о работе и рисках») is visible at a glance before
|
// --- About panel -----------------------------------------------------------
|
||||||
// the user opens the modal. TG / Discord get the plain «вход,
|
// WhatsApp-only Meta-ToS risk disclosure is folded into the About modal as
|
||||||
// безопасность, исходный код» variant because they don't carry an
|
// an amber callout at the top of the body. The About card itself carries
|
||||||
// account-loss risk in the same way (Telegram user ToS doesn't
|
// `command-card warn` (amber border + amber name) and a triangle warning
|
||||||
// forbid third-party clients; Discord's restriction on self-bots
|
// glyph in the lead slot — instead of the info-circle TG / Discord use —
|
||||||
// lives in developer policies, not user ToS proper). The amber
|
// so the «риски» half of the hybrid description («о работе и рисках») is
|
||||||
// block keeps the unique WhatsApp framing without claiming anything
|
// visible at a glance before the user opens the modal. TG / Discord get
|
||||||
// about TG / Discord by comparison.
|
// the plain «вход, безопасность, исходный код» variant because they don't
|
||||||
|
// carry an account-loss risk in the same way (Telegram user ToS doesn't
|
||||||
|
// forbid third-party clients; Discord's restriction on self-bots lives in
|
||||||
|
// developer policies, not user ToS proper).
|
||||||
//
|
//
|
||||||
// ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
|
// ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
|
||||||
// section «Harm To WhatsApp Or Our Users» forbids «software or
|
// section «Harm To WhatsApp Or Our Users» forbids «software or APIs that
|
||||||
// APIs that function substantially the same as our Services» and
|
// function substantially the same as our Services» and «accounts for our
|
||||||
// «accounts for our Services through unauthorized or automated
|
// Services through unauthorized or automated means».
|
||||||
// means».
|
'card.about.name': 'Как работает WhatsApp-бот',
|
||||||
|
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
|
||||||
'warning.title': 'Важно знать до подключения WhatsApp',
|
'warning.title': 'Важно знать до подключения WhatsApp',
|
||||||
'warning.body-1':
|
'warning.body-1':
|
||||||
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
|
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
|
||||||
// Источник про запрет в ToS — даём юзеру возможность дойти до
|
// Источник про запрет в ToS — даём юзеру возможность дойти до оригинала
|
||||||
// оригинала самому, не доверять нам на слово. Кликается потому что
|
// самому, не доверять нам на слово. Кликается потому что host-side iframe
|
||||||
// host-side iframe sandbox получил allow-popups (см.
|
// sandbox получил allow-popups (см. src/app/features/bots/BotWidgetEmbed.ts).
|
||||||
// src/app/features/bots/BotWidgetEmbed.ts).
|
|
||||||
'warning.tos-label': 'Условия использования WhatsApp:',
|
'warning.tos-label': 'Условия использования WhatsApp:',
|
||||||
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
|
||||||
'card.about.name': 'Как работает WhatsApp-бот',
|
|
||||||
// Hybrid copy: tells the user the modal carries BOTH the «как
|
|
||||||
// работает» explainer AND the Meta-ToS risk disclosure. «нажмите,
|
|
||||||
// чтобы прочесть» reinforces interactivity — the amber border +
|
|
||||||
// warning triangle help but the explicit verb seals it.
|
|
||||||
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
|
|
||||||
'about.title': 'О боте WhatsApp',
|
'about.title': 'О боте WhatsApp',
|
||||||
'about.body-1':
|
'about.body-1':
|
||||||
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',
|
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',
|
||||||
'about.body-2':
|
'about.body-2':
|
||||||
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».',
|
'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязка устройства», либо ввести 8-символьный код через «Настройки → Связанные устройства → Связать по номеру телефона».',
|
||||||
'about.body-3':
|
'about.body-3':
|
||||||
'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.',
|
'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.',
|
||||||
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
||||||
|
|
@ -83,8 +67,9 @@ export const RU = {
|
||||||
'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».',
|
'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».',
|
||||||
'about.close': 'Закрыть',
|
'about.close': 'Закрыть',
|
||||||
'about.aria-close': 'Закрыть «О боте»',
|
'about.aria-close': 'Закрыть «О боте»',
|
||||||
// --- Phone form (pairing-code flow) ------------------------------------
|
|
||||||
'auth-card.phone.title': 'Вход по коду из приложения',
|
// --- Phone form (pairing-code flow) -----------------------------------------
|
||||||
|
'auth-card.phone.title': 'Вход по номеру телефона',
|
||||||
'auth-card.phone.label': 'Номер телефона',
|
'auth-card.phone.label': 'Номер телефона',
|
||||||
'auth-card.phone.placeholder': '+79991234567',
|
'auth-card.phone.placeholder': '+79991234567',
|
||||||
// Подсказка, объясняющая что произойдёт после сабмита: мост создаст
|
// Подсказка, объясняющая что произойдёт после сабмита: мост создаст
|
||||||
|
|
@ -95,96 +80,104 @@ export const RU = {
|
||||||
'auth-card.phone.submit': 'Получить код',
|
'auth-card.phone.submit': 'Получить код',
|
||||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||||
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||||
// --- Pairing-code form -------------------------------------------------
|
|
||||||
|
// --- Pairing-code panel --------------------------------------------------
|
||||||
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
||||||
'auth-card.pairing-code.hint':
|
'auth-card.pairing-code.hint':
|
||||||
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».',
|
'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Связать по номеру телефона».',
|
||||||
'auth-card.pairing-code.preparing': 'Готовим код…',
|
|
||||||
'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.',
|
'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.',
|
||||||
'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}',
|
'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}',
|
||||||
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
||||||
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||||
'auth-card.pairing-code.step-3':
|
'auth-card.pairing-code.step-3':
|
||||||
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
'Нажмите «Привязка устройства», затем «Связать по номеру телефона».',
|
||||||
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
||||||
// --- QR form -----------------------------------------------------------
|
'auth-card.pairing-code.copy': 'Скопировать код',
|
||||||
|
'auth-card.pairing-code.copied': 'Скопировано',
|
||||||
|
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
'auth-card.cancel': 'Отмена',
|
||||||
|
'auth-card.waiting-hint': 'Мост ещё думает… ответ может идти до 30 секунд.',
|
||||||
|
|
||||||
|
// --- QR panel ------------------------------------------------------------
|
||||||
'auth-card.qr.title': 'Вход по QR-коду',
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.',
|
'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.',
|
||||||
'auth-card.qr.preparing': 'Готовим QR-код…',
|
'auth-card.qr.preparing': 'Готовим QR-код…',
|
||||||
'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.',
|
'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.',
|
||||||
// Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по
|
// Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по
|
||||||
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в
|
// расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в панели
|
||||||
// панели всегда свежий (мост шлёт m.replace edits на каждой ротации),
|
// всегда свежий (long-poll приносит новый на каждой ротации), отсчёт
|
||||||
// отсчёт показывает оставшееся окно ВСЕГО входа.
|
// показывает оставшееся окно ВСЕГО входа.
|
||||||
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
||||||
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.',
|
'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.',
|
||||||
'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||||
'auth-card.qr.step-3': 'Нажмите «Привязать устройство» и отсканируйте QR-код.',
|
'auth-card.qr.step-3': 'Нажмите «Привязка устройства» и отсканируйте QR-код.',
|
||||||
// --- Shared form chrome ------------------------------------------------
|
|
||||||
'auth-card.cancel': 'Отмена',
|
// --- Global errors / notices ---------------------------------------------
|
||||||
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
'error.network': 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.',
|
||||||
// --- Inline errors -----------------------------------------------------
|
'error.auth':
|
||||||
// login_failed reasons — мы сохраняем верхатимный текст ошибки от
|
'Не удалось подтвердить ваш аккаунт у моста. Обновите страницу; если не помогает — попробуйте позже.',
|
||||||
// upstream. Это даёт юзеру максимально точную диагностику без перевода,
|
'error.not-logged-in':
|
||||||
// которое может разъехаться с реальной причиной. Шаблон обёрнут.
|
'Сессия WhatsApp больше не активна. Вернитесь назад и привяжите аккаунт заново.',
|
||||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
'error.openid-blocked':
|
||||||
'auth-error.invalid-value': 'Значение не принято: {reason}',
|
'Хост не выдал виджету разрешение на вход — в config.json у бота нет capability vojo.openid.',
|
||||||
'auth-error.submit-failed': 'WhatsApp не принял ввод: {reason}',
|
'error.rate-limited': 'WhatsApp просит подождать: слишком много запросов кода. Попробуйте позже.',
|
||||||
'auth-error.start-failed': 'Не удалось начать вход: {reason}',
|
'error.phone-invalid':
|
||||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
'WhatsApp не принял этот номер. Укажите его в международном формате и попробуйте снова.',
|
||||||
'auth-error.login-in-progress':
|
'error.multidevice':
|
||||||
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.',
|
'На телефоне не включён мультиустройственный режим WhatsApp. Обновите приложение WhatsApp и попробуйте снова.',
|
||||||
'auth-error.max-logins':
|
'error.client-outdated':
|
||||||
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.',
|
'Мост устарел, и WhatsApp отказывает ему в подключении. Сообщите администратору Vojo.',
|
||||||
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
'error.pair-failed': 'WhatsApp не подтвердил привязку. Начните вход заново.',
|
||||||
// External-logout варианты — три причины, у каждой своя UX-формулировка.
|
'error.login-timeout': 'Время входа истекло. Начните вход заново.',
|
||||||
// «another_device» — другой связанный девайс отвязал нас (например, юзер
|
'error.login-restart': 'Сессия входа потерялась. Начните вход заново.',
|
||||||
// отвязал bridge с другого ноутбука). «phone_logged_out» — юзер вышел
|
'error.too-many-logins': 'Достигнут лимит привязанных аккаунтов. Сначала выйдите из текущего.',
|
||||||
// из WhatsApp на самом телефоне, что ломает все связанные устройства.
|
'error.generic': 'Что-то пошло не так: {reason}',
|
||||||
// «unknown» — fallback, в т.ч. для startup-нотисов «You're not logged
|
'notice.login-success': 'WhatsApp привязан! Чаты появятся в списке в течение минуты.',
|
||||||
// into WhatsApp».
|
'notice.logged-out': 'Сеанс WhatsApp завершён.',
|
||||||
'auth-error.external-logout.another-device':
|
|
||||||
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
// --- Contacts ------------------------------------------------------------
|
||||||
'auth-error.external-logout.phone-logged-out':
|
'card.contacts.name': 'Контакты',
|
||||||
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
'card.contacts.desc': 'Записная книжка WhatsApp: поиск по имени или номеру',
|
||||||
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
|
'contacts.back': 'Назад',
|
||||||
// --- Logout ------------------------------------------------------------
|
'contacts.search-placeholder': 'Имя или +номер…',
|
||||||
'card.logout.name': 'Выйти из WhatsApp',
|
'contacts.hint':
|
||||||
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
'Это контакты вашей записной книжки WhatsApp. Выберите, с кем начать чат в Vojo, — остальные никуда не денутся.',
|
||||||
'card.logout.confirm-prompt': 'Точно выйти?',
|
'contacts.loading': 'Загружаем контакты…',
|
||||||
'card.logout.confirm-yes': 'Выйти',
|
'contacts.error': 'Не удалось загрузить контакты.',
|
||||||
'card.logout.confirm-no': 'Отмена',
|
'contacts.retry': 'Повторить',
|
||||||
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.',
|
'contacts.empty': 'В записной книжке WhatsApp пока пусто.',
|
||||||
// --- Diagnostics in transcript ----------------------------------------
|
'contacts.empty-filtered': 'Никого не нашли с таким именем.',
|
||||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
'contacts.start-chat': 'Начать чат',
|
||||||
'diag.ready': 'Готов отправлять команды.',
|
'contacts.open-chat': 'Открыть чат',
|
||||||
'diag.checking-status': 'Проверяю статус подключения…',
|
'contacts.creating': 'Создаём чат…',
|
||||||
'diag.send-failed': 'ошибка отправки: {message}',
|
'contacts.opening': 'Открываем…',
|
||||||
'diag.history-marker': '─── история ───',
|
'contacts.probe-check': 'Проверить {handle} в WhatsApp',
|
||||||
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
|
'contacts.probe-checking': 'Проверяем {handle}…',
|
||||||
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
|
'contacts.probe-not-found': '{handle} не найден в WhatsApp.',
|
||||||
// raw whatsmeow handshake (включая adv-secret, который IS the login
|
'contacts.probe-found': 'Есть такой! Можно написать.',
|
||||||
// token). Сохранять его в DOM-логе виджета означало бы пережить мост-
|
'contacts.probe-self': 'Это ваш собственный номер.',
|
||||||
// редакцию. В логе только нейтральные диагностические строки.
|
'contacts.refresh': 'Обновить список',
|
||||||
'diag.qr-issued': 'QR-код обновлён.',
|
|
||||||
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
// --- Account tab -----------------------------------------------------------
|
||||||
// Pairing-код — не такой же чувствительный как QR adv-secret (это
|
'account.state-bad':
|
||||||
// 8-символьный one-time pairing token, действителен ~3 минуты), но
|
'Мост сообщает о проблеме с подключением: {reason}. Попробуйте выйти и привязать WhatsApp заново.',
|
||||||
// всё равно по аналогии с QR не дублируем его в transcript — UI и так
|
// TRANSIENT_DISCONNECT — телефон давно не в сети / keepalive потерян
|
||||||
// показывает код большим моноширинным текстом. В логе только нейтральная
|
// (connector phoneping.go). Обычно чинится само, перепривязка не нужна.
|
||||||
// диагностика, чтобы trail был последовательный.
|
'account.state-transient':
|
||||||
'diag.pairing-code-issued': 'Код для входа выдан.',
|
'Мост временно потерял связь с WhatsApp. Проверьте, что телефон в сети, — обычно подключение восстанавливается само.',
|
||||||
// Connection warnings от connector handlewhatsapp.go — они не меняют
|
|
||||||
// state виджета, просто пишутся в transcript verbatim, чтобы юзер
|
// --- Boot / config ---------------------------------------------------------
|
||||||
// понимал, что мост борется с подключением.
|
'boot.connecting': 'Подключение к мосту…',
|
||||||
'diag.connection-warning': '{text}',
|
'config.missing.title': 'Нужна настройка на сервере',
|
||||||
// External-logout transcript echo — короткая строка под красным
|
'config.missing.body':
|
||||||
// баннером.
|
'Виджет работает через API моста, но его адрес не задан в конфигурации (experience.provisioningUrl в config.json) или не выдано разрешение vojo.openid.',
|
||||||
'diag.external-logout': 'WhatsApp разорвал сессию — нужен повторный вход.',
|
'error.retry': 'Повторить',
|
||||||
|
|
||||||
// --- Bootstrap failure -------------------------------------------------
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
'bootstrap.failed': 'Widget не запустился',
|
'bootstrap.failed': 'Виджет не запустился',
|
||||||
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
821
apps/widget-whatsapp/src/login.tsx
Normal file
821
apps/widget-whatsapp/src/login.tsx
Normal 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
|
||||||
|
* cancel→reopen 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,15 +2,29 @@ import { render } from 'preact';
|
||||||
import { readBootstrap } from './bootstrap';
|
import { readBootstrap } from './bootstrap';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createT } from './i18n';
|
import { createT } from './i18n';
|
||||||
import { WidgetApi, buildCapabilities } from './widget-api';
|
import { WidgetApi } from './widget-api';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
// Input-mode detector for hover styling. CSS gates `:hover` and
|
||||||
// full rationale. Default to 'mouse'; the capture-phase pointerdown
|
// `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's
|
||||||
// listener flips to 'touch' on the first non-mouse pointerType.
|
// Android Chromium WebView synthesises `:hover` on the focused element
|
||||||
// matchMedia guessing was dropped — every variant
|
// after a tap and never clears it until the next interaction elsewhere —
|
||||||
// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
|
// without the gate, every tap leaves a sticky hover state on the tapped
|
||||||
// is mis-reported on at least one shipping device.
|
// card («card greys out after tap and only un-greys when you tap a
|
||||||
|
// different button»).
|
||||||
|
//
|
||||||
|
// Truth comes from `pointerdown.pointerType`. The capture-phase listener
|
||||||
|
// runs in the same task as any post-tap `:hover` synthesis, so a touch
|
||||||
|
// tap on Android lands in 'touch' mode in the same render frame as the
|
||||||
|
// synthesised hover would paint.
|
||||||
|
//
|
||||||
|
// Initial mode is plain 'mouse' — matchMedia-based guessing was tried and
|
||||||
|
// dropped: every interaction-media query is mis-reported on at least one
|
||||||
|
// shipping device (see git history for the survey). Defaulting to 'mouse'
|
||||||
|
// is strictly no worse on any device: a desktop user gets hover from frame
|
||||||
|
// zero, and a touch user cannot trigger `:hover` before tapping — by the
|
||||||
|
// time the first tap fires, our listener has already moved the attribute
|
||||||
|
// to 'touch'.
|
||||||
const setInputMode = (mode: 'touch' | 'mouse'): void => {
|
const setInputMode = (mode: 'touch' | 'mouse'): void => {
|
||||||
document.documentElement.dataset.input = mode;
|
document.documentElement.dataset.input = mode;
|
||||||
};
|
};
|
||||||
|
|
@ -52,19 +66,12 @@ if (!result.ok) {
|
||||||
// through the wrong palette.
|
// through the wrong palette.
|
||||||
document.documentElement.dataset.theme = result.bootstrap.theme;
|
document.documentElement.dataset.theme = result.bootstrap.theme;
|
||||||
|
|
||||||
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
|
// Instantiate the WidgetApi BEFORE the first render. The constructor
|
||||||
// the `window.addEventListener('message', ...)` listener synchronously,
|
// attaches the `window.addEventListener('message', ...)` listener
|
||||||
// so by the time the host's ClientWidgetApi fires its capabilities
|
// synchronously, so by the time the host's ClientWidgetApi fires its
|
||||||
// request on iframe `load` we're already listening.
|
// capabilities request on iframe `load` we're already listening. On a
|
||||||
//
|
// cached-bundle remount the request can race ahead of any useEffect —
|
||||||
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
|
// construction at module-load closes that window.
|
||||||
// runs AFTER React's first commit. On a fresh mount the bundle parse +
|
const api = new WidgetApi(result.bootstrap);
|
||||||
// initial render took long enough for the host's request to arrive
|
|
||||||
// after the listener was attached, so it worked by accident. On the
|
|
||||||
// *second* mount (after «Show chat» → «Show widget») the bundle is
|
|
||||||
// browser-cached and parses near-instantly; the host's request raced
|
|
||||||
// ahead of useEffect, the listener missed it, and capability handshake
|
|
||||||
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
|
|
||||||
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
|
||||||
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
355
apps/widget-whatsapp/src/provisioning.ts
Normal file
355
apps/widget-whatsapp/src/provisioning.ts
Normal 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
205
apps/widget-whatsapp/src/ui.tsx
Normal file
205
apps/widget-whatsapp/src/ui.tsx
Normal 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" />;
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
// Minimal matrix-widget-api transport implemented inline. We don't pull
|
// Minimal matrix-widget-api transport implemented inline. We don't pull
|
||||||
// the full SDK because:
|
// the full SDK because the surface we use is tiny: the capability
|
||||||
// - it's CommonJS and forces ESM interop juggling that we hit on the
|
// handshake (we request NO capabilities — the bridge is driven over its
|
||||||
// dev fixture in the Telegram widget's M2 phase (esm.sh wrapping made
|
// provisioning HTTP API, not over room events), theme_change pushes,
|
||||||
// WidgetApi unavailable as a constructor);
|
// MSC1960 OpenID credentials, and two `io.vojo.bot-widget` side-channel
|
||||||
// - the surface we use is small: capabilities reply, theme_change reply,
|
// verbs (open-external-url / open-matrix-to).
|
||||||
// send_event request, read_events request, get_openid request, live
|
|
||||||
// event delivery via send_event toWidget.
|
|
||||||
//
|
//
|
||||||
// Protocol shapes match
|
// Protocol shapes match
|
||||||
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||||
// (in the host repo). Default request timeout on the host transport is
|
// in the host repo.
|
||||||
// 10 s — keep that in mind for bridge-bot replies that take time.
|
|
||||||
|
|
||||||
import type { WidgetBootstrap } from './bootstrap';
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
|
||||||
export type RoomEvent = {
|
|
||||||
type: string;
|
|
||||||
event_id: string;
|
|
||||||
room_id: string;
|
|
||||||
sender: string;
|
|
||||||
origin_server_ts: number;
|
|
||||||
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
|
||||||
unsigned: Record<string, unknown>;
|
|
||||||
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
|
||||||
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
|
||||||
// for forward-compat; the widget-side parser reads either.
|
|
||||||
redacts?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToWidgetMessage = {
|
type ToWidgetMessage = {
|
||||||
api: 'toWidget';
|
api: 'toWidget';
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
|
|
@ -35,8 +18,6 @@ type ToWidgetMessage = {
|
||||||
action: string;
|
action: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
// Present when this message IS a reply to a prior toWidget request.
|
// Present when this message IS a reply to a prior toWidget request.
|
||||||
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
|
||||||
// `api` field and add `response`. Both directions follow the same shape.
|
|
||||||
response?: Record<string, unknown>;
|
response?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,16 +30,36 @@ type FromWidgetMessage = {
|
||||||
response?: Record<string, unknown>;
|
response?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Capability = string;
|
export type OpenIdCredentials = {
|
||||||
|
accessToken: string;
|
||||||
|
/** Seconds until the token expires (Synapse default 3600). */
|
||||||
|
expiresIn: number;
|
||||||
|
matrixServerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WidgetApiEvents = {
|
export type WidgetApiEvents = {
|
||||||
ready: () => void;
|
ready: () => void;
|
||||||
liveEvent: (ev: RoomEvent) => void;
|
|
||||||
themeChange: (name: 'light' | 'dark') => void;
|
themeChange: (name: 'light' | 'dark') => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
// MSC1960 has a two-phase path: the host may reply `state: "request"`
|
||||||
|
// (user confirmation pending) and deliver the credentials later via a
|
||||||
|
// separate `openid_credentials` toWidget action. Vojo's host auto-grants
|
||||||
|
// for allowlisted first-party widgets, so phase 2 normally never happens —
|
||||||
|
// the generous timeout only matters if a future host adds a consent UI.
|
||||||
|
const OPENID_PHASE2_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
const parseOpenIdCredentials = (raw: Record<string, unknown>): OpenIdCredentials | null => {
|
||||||
|
const accessToken = raw.access_token;
|
||||||
|
const matrixServerName = raw.matrix_server_name;
|
||||||
|
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
|
||||||
|
if (typeof matrixServerName !== 'string' || matrixServerName.length === 0) return null;
|
||||||
|
const expiresIn = typeof raw.expires_in === 'number' ? raw.expires_in : 3600;
|
||||||
|
return { accessToken, expiresIn, matrixServerName };
|
||||||
|
};
|
||||||
|
|
||||||
export class WidgetApi {
|
export class WidgetApi {
|
||||||
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||||
|
|
||||||
|
|
@ -67,14 +68,19 @@ export class WidgetApi {
|
||||||
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
// Phase-2 OpenID waiters keyed by the ORIGINAL get_openid requestId —
|
||||||
|
// the host's `openid_credentials` action carries it as
|
||||||
|
// `original_request_id`.
|
||||||
|
private readonly pendingOpenId = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (v: OpenIdCredentials) => void; reject: (e: Error) => void; timer: number }
|
||||||
|
>();
|
||||||
|
|
||||||
private requestSeq = 0;
|
private requestSeq = 0;
|
||||||
|
|
||||||
private isReady = false;
|
private isReady = false;
|
||||||
|
|
||||||
public constructor(
|
public constructor(private readonly bootstrap: WidgetBootstrap) {
|
||||||
private readonly bootstrap: WidgetBootstrap,
|
|
||||||
private readonly capabilities: Capability[]
|
|
||||||
) {
|
|
||||||
window.addEventListener('message', this.onMessage);
|
window.addEventListener('message', this.onMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +88,11 @@ export class WidgetApi {
|
||||||
window.removeEventListener('message', this.onMessage);
|
window.removeEventListener('message', this.onMessage);
|
||||||
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
|
this.pendingOpenId.forEach(({ reject, timer }) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(new Error('disposed'));
|
||||||
|
});
|
||||||
|
this.pendingOpenId.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||||
|
|
@ -90,83 +101,82 @@ export class WidgetApi {
|
||||||
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||||
// before this listener attached (cached-bundle race: host fires the
|
// before this listener attached (cached-bundle race: host fires the
|
||||||
// capabilities request on iframe `load`, the WidgetApi catches and
|
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||||
// resolves it during script init, then React's useEffect runs *after*
|
// resolves it during script init, then Preact's useEffect runs *after*
|
||||||
// that and attaches the `ready` listener), replay synchronously so
|
// that and attaches the `ready` listener), replay synchronously.
|
||||||
// App.tsx still flips `handshakeOk` and fires `list-logins`.
|
|
||||||
if (event === 'ready' && this.isReady) {
|
if (event === 'ready' && this.isReady) {
|
||||||
(listener as () => void)();
|
(listener as () => void)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendText(body: string): Promise<{ event_id: string }> {
|
// MSC1960: ask the host for OpenID credentials proving our Matrix
|
||||||
return this.fromWidget('send_event', {
|
// identity. The provisioning client exchanges them with the bridge
|
||||||
type: 'm.room.message',
|
// (`Authorization: Bearer openid:<token>`); they are NOT a Matrix access
|
||||||
content: { msgtype: 'm.text', body },
|
// token and grant no homeserver power. Rejects when the host driver
|
||||||
}) as Promise<{ event_id: string }>;
|
// blocks the request (BotWidgetDriver gates on the `vojo.openid`
|
||||||
|
// capability opt-in in config.json).
|
||||||
|
public async getOpenIdCredentials(): Promise<OpenIdCredentials> {
|
||||||
|
const requestId = this.nextRequestId();
|
||||||
|
const reply = await this.fromWidget('get_openid', {}, requestId);
|
||||||
|
const state = reply.state;
|
||||||
|
if (state === 'allowed') {
|
||||||
|
const creds = parseOpenIdCredentials(reply);
|
||||||
|
if (!creds) throw new Error('host returned malformed OpenID credentials');
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
if (state === 'request') {
|
||||||
|
// Phase 2: credentials arrive via the `openid_credentials` action.
|
||||||
|
return new Promise<OpenIdCredentials>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
this.pendingOpenId.delete(requestId);
|
||||||
|
reject(new Error('OpenID confirmation timed out'));
|
||||||
|
}, OPENID_PHASE2_TIMEOUT_MS);
|
||||||
|
this.pendingOpenId.set(requestId, { resolve, reject, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error('OpenID request blocked by host');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSC4039 media download. The host driver only honours mxc URIs and
|
||||||
|
// substitutes a 96px crop thumbnail — this exists for avatars, not for a
|
||||||
|
// media viewer. Media on the homeserver is authenticated; the host fetches
|
||||||
|
// with its token and ships the bytes here as a Blob (structured clone).
|
||||||
|
public async downloadFile(mxcUri: string): Promise<Blob> {
|
||||||
|
const reply = await this.fromWidget('org.matrix.msc4039.download_file', {
|
||||||
|
content_uri: mxcUri,
|
||||||
|
});
|
||||||
|
const file = (reply as { file?: unknown }).file;
|
||||||
|
if (file instanceof Blob) return file;
|
||||||
|
if (file === undefined || file === null) throw new Error('host returned no file');
|
||||||
|
// Other XMLHttpRequestBodyInit shapes (ArrayBuffer, string) — normalize.
|
||||||
|
return new Blob([file as BlobPart]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open an external URL via the host. The host receives this on a
|
// Open an external URL via the host. The host receives this on a
|
||||||
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
||||||
// matrix-widget-api's `fromWidget` so it doesn't route through
|
// matrix-widget-api's `fromWidget` so it doesn't route through
|
||||||
// ClientWidgetApi's request/response machinery.
|
// ClientWidgetApi's request/response machinery. Needed because
|
||||||
//
|
// cross-origin iframes inside Capacitor's Android WebView silently drop
|
||||||
// Why this exists: cross-origin iframes inside Capacitor's Android
|
// `<a target="_blank">` clicks.
|
||||||
// WebView silently drop `<a target="_blank">` clicks — the WebView
|
|
||||||
// doesn't have a multi-window concept, and the host's global
|
|
||||||
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
|
||||||
// inside the host document, not inside the iframe (cross-origin
|
|
||||||
// events don't bubble across the frame boundary). The widget posts
|
|
||||||
// this message instead; the host calls `openExternalUrl(url)` which
|
|
||||||
// routes to `Browser.open` on native and `window.open` on web.
|
|
||||||
public openExternalUrl(url: string): void {
|
public openExternalUrl(url: string): void {
|
||||||
|
this.postSideChannel('open-external-url', { url });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the host to navigate to a Matrix room. The host validates the URL
|
||||||
|
// through `parseMatrixToRoom` and picks the destination surface (bridge
|
||||||
|
// space in Каналы / /direct/) — see BotWidgetMount.handleOpenMatrixToRoom.
|
||||||
|
public openMatrixRoom(roomId: string): void {
|
||||||
|
this.postSideChannel('open-matrix-to', {
|
||||||
|
url: `https://matrix.to/#/${encodeURIComponent(roomId)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private postSideChannel(action: string, data: Record<string, unknown>): void {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{ api: 'io.vojo.bot-widget', action, data },
|
||||||
api: 'io.vojo.bot-widget',
|
|
||||||
action: 'open-external-url',
|
|
||||||
data: { url },
|
|
||||||
},
|
|
||||||
this.bootstrap.parentOrigin
|
this.bootstrap.parentOrigin
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
|
|
||||||
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
|
||||||
// the management room and any other room the bot may have been moved to.
|
|
||||||
// Form-field submissions (phone number) go through this same helper because
|
|
||||||
// bridgev2's stored CommandState fallback only fires after queue.go:108
|
|
||||||
// routes the message — and that route also requires the prefix outside the
|
|
||||||
// management room.
|
|
||||||
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
|
||||||
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
|
||||||
return this.sendText(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
|
||||||
// capability is MSC2762 timeline (already requested at construction). We
|
|
||||||
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
|
||||||
// ClientWidgetApi takes the modern code path that calls our driver's
|
|
||||||
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
|
||||||
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
|
||||||
// to chronological order is the caller's job.
|
|
||||||
//
|
|
||||||
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
|
||||||
// post-scan cleanup events. `msgtype` is honoured only for m.room.message
|
|
||||||
// (matches the driver's `readRoomTimeline` semantics).
|
|
||||||
public async readTimeline(opts: {
|
|
||||||
limit: number;
|
|
||||||
type?: 'm.room.message' | 'm.room.redaction';
|
|
||||||
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
|
||||||
}): Promise<RoomEvent[]> {
|
|
||||||
const data: Record<string, unknown> = {
|
|
||||||
type: opts.type ?? 'm.room.message',
|
|
||||||
limit: opts.limit,
|
|
||||||
room_ids: [this.bootstrap.roomId],
|
|
||||||
};
|
|
||||||
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
|
||||||
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
|
||||||
return (res.events as RoomEvent[] | undefined) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit<K extends keyof WidgetApiEvents>(
|
private emit<K extends keyof WidgetApiEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
...args: Parameters<WidgetApiEvents[K]>
|
...args: Parameters<WidgetApiEvents[K]>
|
||||||
|
|
@ -190,11 +200,10 @@ export class WidgetApi {
|
||||||
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||||
// Source-window guard: every legit widget API message comes from the
|
// Source-window guard: every legit widget API message comes from the
|
||||||
// host window that embedded our iframe — i.e. window.parent. A foreign
|
// host window that embedded our iframe — i.e. window.parent. A foreign
|
||||||
// tab/frame on the same origin (think browser extension content
|
// tab/frame on the same origin (browser extension content script,
|
||||||
// script, popup, or sibling iframe) could otherwise post a forged
|
// popup, sibling iframe) could otherwise post a forged message that
|
||||||
// message that passes the origin check. We only accept messages
|
// passes the origin check. The `widgetId` check below is a soft
|
||||||
// whose `source` is literally `window.parent`. The `widgetId` check
|
// filter; this is the hard one.
|
||||||
// a few lines down is a soft filter; this is the hard one.
|
|
||||||
if (ev.source !== window.parent) return;
|
if (ev.source !== window.parent) return;
|
||||||
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||||
if (!msg || typeof msg !== 'object') return;
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
|
@ -230,7 +239,11 @@ export class WidgetApi {
|
||||||
if (!msg.requestId || !msg.action) return;
|
if (!msg.requestId || !msg.action) return;
|
||||||
switch (msg.action) {
|
switch (msg.action) {
|
||||||
case 'capabilities': {
|
case 'capabilities': {
|
||||||
this.replyTo(msg, { capabilities: this.capabilities });
|
// No MSC2762 capabilities — no timeline reads, no message sends. The
|
||||||
|
// only capability the HTTP-transport widget needs is MSC4039 media
|
||||||
|
// download, used for contact/profile avatar thumbnails (media on the
|
||||||
|
// homeserver is authenticated and the widget holds no Matrix token).
|
||||||
|
this.replyTo(msg, { capabilities: ['org.matrix.msc4039.download_file'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'notify_capabilities': {
|
case 'notify_capabilities': {
|
||||||
|
|
@ -242,7 +255,7 @@ export class WidgetApi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'supported_api_versions': {
|
case 'supported_api_versions': {
|
||||||
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc1960'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'theme_change': {
|
case 'theme_change': {
|
||||||
|
|
@ -252,27 +265,19 @@ export class WidgetApi {
|
||||||
this.replyTo(msg, {});
|
this.replyTo(msg, {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'send_event': {
|
case 'openid_credentials': {
|
||||||
// Live event push from host. Forward `m.room.message` (carries the
|
// Phase-2 MSC1960 delivery after a `state: "request"` reply.
|
||||||
// bot's notices / errors / `m.image` QR-login broadcasts AND the
|
const originalId = msg.data?.original_request_id;
|
||||||
// pairing-code text) AND `m.room.redaction` (post-scan QR cleanup,
|
if (typeof originalId === 'string') {
|
||||||
// see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State
|
const waiter = this.pendingOpenId.get(originalId);
|
||||||
// events (m.room.member) also arrive on this channel — we still
|
if (waiter) {
|
||||||
// ignore them here.
|
this.pendingOpenId.delete(originalId);
|
||||||
const data = msg.data as Partial<RoomEvent> | undefined;
|
window.clearTimeout(waiter.timer);
|
||||||
if (
|
const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null;
|
||||||
data &&
|
if (creds) waiter.resolve(creds);
|
||||||
data.event_id &&
|
else waiter.reject(new Error('OpenID request blocked by host'));
|
||||||
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
|
||||||
) {
|
|
||||||
this.emit('liveEvent', data as RoomEvent);
|
|
||||||
}
|
}
|
||||||
this.replyTo(msg, {});
|
|
||||||
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, {});
|
this.replyTo(msg, {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -285,10 +290,10 @@ export class WidgetApi {
|
||||||
|
|
||||||
private fromWidget(
|
private fromWidget(
|
||||||
action: string,
|
action: string,
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>,
|
||||||
|
requestId = this.nextRequestId()
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestId = this.nextRequestId();
|
|
||||||
this.pending.set(requestId, { resolve, reject });
|
this.pending.set(requestId, { resolve, reject });
|
||||||
this.postToHost({
|
this.postToHost({
|
||||||
api: 'fromWidget',
|
api: 'fromWidget',
|
||||||
|
|
@ -306,22 +311,3 @@ export class WidgetApi {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
|
|
||||||
// Anything else is silently dropped by the host's validateCapabilities.
|
|
||||||
//
|
|
||||||
// `m.image` and `m.room.redaction` are the QR-login additions (already in
|
|
||||||
// place from the Telegram widget M13). The host sanitizer for `m.image`
|
|
||||||
// strips `url` / `file` / `info`, leaving only `body` (the bridge encodes
|
|
||||||
// the QR payload there) plus `m.relates_to` / `m.new_content` for QR
|
|
||||||
// rotation edits. Redactions signal that the QR was consumed by a
|
|
||||||
// successful scan.
|
|
||||||
export const buildCapabilities = (roomId: string): Capability[] => [
|
|
||||||
`org.matrix.msc2762.timeline:${roomId}`,
|
|
||||||
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
|
||||||
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
|
||||||
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
|
||||||
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
|
||||||
'org.matrix.msc2762.receive.event:m.room.redaction',
|
|
||||||
'org.matrix.msc2762.receive.state_event:m.room.member',
|
|
||||||
];
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@
|
||||||
"experience": {
|
"experience": {
|
||||||
"type": "matrix-widget",
|
"type": "matrix-widget",
|
||||||
"url": "https://widgets.vojo.chat/whatsapp/index.html",
|
"url": "https://widgets.vojo.chat/whatsapp/index.html",
|
||||||
"commandPrefix": "!wa"
|
"provisioningUrl": "https://vojo.chat/_provision/whatsapp",
|
||||||
|
"capabilities": ["vojo.openid"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue