diff --git a/apps/widget-whatsapp/.gitignore b/apps/widget-whatsapp/.gitignore new file mode 100644 index 00000000..205a6bfb --- /dev/null +++ b/apps/widget-whatsapp/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +*.local diff --git a/apps/widget-whatsapp/README.md b/apps/widget-whatsapp/README.md index c219e70e..2631db52 100644 --- a/apps/widget-whatsapp/README.md +++ b/apps/widget-whatsapp/README.md @@ -1,84 +1,114 @@ # @vojo/widget-whatsapp Vojo WhatsApp bridge management widget — mounts inside `/bots/whatsapp` -in the Vojo client. Drives the mautrix-whatsapp bridge bot -(`@whatsappbot:vojo.chat`) by sending bridgev2 commands in the control DM -and rendering the bot's text replies into a typed login flow. +in the Vojo client. -This is **not** a WhatsApp client — Vojo continues using the Matrix room -the bridge writes to. The widget is a panel that handles authentication -(QR scan or pairing code) and surfaces session status. +This is **not** a WhatsApp client. It's a control panel for the +mautrix-whatsapp bridge that talks to the bridge's **provisioning HTTP +API** (bridgev2 `/_matrix/provision/v3/*`, exposed by Caddy at +`https://vojo.chat/_provision/whatsapp`). It links the account (QR scan +or an 8-character pairing code typed into the WhatsApp app), shows the +linked account, lists WhatsApp contacts, resolves +phone numbers, and +creates DM portals on demand. + +Auth: the widget requests MSC1960 OpenID credentials from the host +(`get_openid` over the widget API; granted by `BotWidgetDriver.askOpenID` +when config.json opts the bot into the `vojo.openid` capability) and +sends them to the bridge as `Authorization: Bearer openid:`. The +OpenID token only proves identity — it is not a Matrix access token. + +There is no bot text-command transport and no reply parsing: the legacy +`!wa`-command dialect (`bridge-protocol/`) was deleted when the bridge +API became reachable. The bot control DM still exists (BotShell needs a +room and the «Show chat» fallback), the widget just doesn't read or +write it — it requests **zero** MSC2762 capabilities. + +## WhatsApp-specific contract notes + +Extracted from mautrix-whatsapp v0.2604.0 (`pkg/connector/login.go`, +`startchat.go`) + mautrix-go v0.27.0 (`bridgev2/matrix/provisioning.go`): + +- Login flows: `qr` and `phone`. The phone flow's submit answers with a + `display_and_wait` step of type **`code`** — an `XXXX-XXXX` pairing + code the user types into the WhatsApp app (NOT an SMS OTP entered into + the widget like Telegram's flow). +- The login window is ~160 s: whatsmeow issues ~6 QR tokens (60 s + 5×20 s) + and closes the socket when they run out; the pairing code lives in the + same window. +- There are **no** `.incorrect` retry steps: every rejected input is a + `FI.MAU.WHATSAPP.*` RespError that deletes the login process + server-side. The phone form transparently starts a fresh process on + resubmit. +- Login-liveness semantics differ by bridge generation. mautrix ≤ v0.28.0: + the login process dies with the poll's request context (and there is + **no** `/v3/login/cancel` route — cancelling works by aborting the + long-poll). mautrix ≥ v0.28.1 (`provisioninglogin.go`): the process + lives server-side with a 30-minute TTL, dropped polls reattach, and the + cancel route exists. The widget's wait loop is written for both: it + retries transport-shaped poll failures with backoff (Android freezes + the backgrounded WebView while the user enters the pairing code in + WhatsApp), wakes on return to foreground, and disambiguates a late + 404 via whoami (the login may have COMPLETED while frozen). +- Identifiers are phone numbers only (`tel:+…`); no usernames. Contact + ids are bare phone digits (or `lid-`/`bot-` prefixed for + hidden-number/bot peers). + +The About card and modal carry the **Meta-ToS risk disclosure** +(`warning.*` keys, amber warn styling, triangle icon) — WhatsApp's terms +of service forbid third-party clients and Meta may ban accounts for it. +This copy is a deliberate product/legal requirement: keep it intact when +restyling. ## Layout ``` src/ -├── bootstrap.ts Parse URL params the host appends (mirrors BotWidgetEmbed.ts) -├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK) -├── App.tsx UI: login forms, QR / pairing-code panels, transcript pane -├── main.tsx Entry: init bootstrap, render App or diagnostic -├── styles.css Theme-aware CSS (Vojo Dawn palette) -├── state.ts Login state machine + hydrate-from-timeline -├── i18n/ Russian primary + English fallback -└── bridge-protocol/ - ├── types.ts LoginEvent discriminated union - ├── parser.ts Dispatch shim - └── dialects/ - └── bridgev2_v0264.ts Regex table pinned to mautrix-whatsapp v0.26.4 +├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts) +├── widget-api.ts Inline matrix-widget-api postMessage transport: handshake, +│ theme, MSC1960 get_openid, io.vojo.bot-widget verbs +├── provisioning.ts Typed client for the bridgev2 provisioning API + identifier +│ helpers (wire contract documented in the file header) +├── errors.ts Bridge/WhatsApp errcode → localized copy mapping +├── login.tsx Login flow over the v3 step machine + forms (phone form, +│ QR panel and pairing-code panel with long-poll) +├── contacts.tsx Contacts list, search-as-filter, phone probe, create DM +├── App.tsx Shell: boot/disconnected/connected phases, account view, +│ About modal with the Meta-ToS warning callout +├── ui.tsx Icons, initials avatar, command cards, notices +├── main.tsx Entry: init bootstrap, render App or diagnostic +└── styles.css Telegram-widget stylesheet verbatim + WhatsApp-only + additions (warn card, ToS callout, pairing-code plate) ``` -## 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 (`,,,`, - 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: -org.matrix.msc2762.send.event:m.room.message#m.text -org.matrix.msc2762.receive.event:m.room.message#m.text -org.matrix.msc2762.receive.event:m.room.message#m.notice -org.matrix.msc2762.receive.event:m.room.message#m.image -org.matrix.msc2762.receive.event:m.room.redaction -org.matrix.msc2762.receive.state_event:m.room.member -``` - -Anything else is silently dropped by the host. The capability set is -identical to the Telegram widget's M13 expansion — the host driver -already supports `m.image` + `m.room.redaction`. - ## Local development +**Don't touch the committed `config.json`.** Create `config.local.json` at +the project root once — gitignored, never deployed. The host's Vite dev +server overlays it on top of `/config.json` responses (see +`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the +overlay entirely. + +Note the overlay merges bot entries **shallowly** — your local +`experience` object replaces the base one wholesale, so it must carry +`provisioningUrl` and `capabilities` too: + ```bash +# one-time: install widget deps cd apps/widget-whatsapp && npm install -cd /home/ubuntu/projects/vojo/cinny && cat > config.local.json <<'JSON' + +# one-time: create config.local.json (gitignored) at the project root +cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON' { "bots": [ - { "id": "whatsapp", "experience": { "type": "matrix-widget", "url": "http://localhost:8083/" } } + { + "id": "whatsapp", + "experience": { + "type": "matrix-widget", + "url": "http://localhost:8083/", + "provisioningUrl": "https://vojo.chat/_provision/whatsapp", + "capabilities": ["vojo.openid"] + } + } ] } JSON @@ -94,8 +124,18 @@ cd apps/widget-whatsapp && npm run dev cd /home/ubuntu/projects/vojo/cinny && npm start ``` -Open `http://localhost:8080/bots/whatsapp`. The host's URL validator -accepts `http://localhost:*` only in dev builds. +Open `http://localhost:8080/bots/whatsapp`. Iframe loads cross-origin +from the widget dev server, HMR works, no proxy. The provisioning calls +go straight to the prod bridge API (CORS `*` + per-request bearer auth), +acting on whatever account you're signed in with — same trust model as +the dev client talking to the prod homeserver. + +`http://localhost:*` URLs are accepted by the host's URL validator only +in dev builds (`import.meta.env.DEV` branch in +`src/app/features/bots/catalog.ts`); production builds drop the branch +via Vite's dead-code elimination, AND production-only enforces an origin +allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even +if config.json is poisoned. ## Build @@ -103,35 +143,51 @@ accepts `http://localhost:*` only in dev builds. npm run build ``` -Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` into -`~/vojo/widgets/whatsapp/` on the production host. The VSCode task -`Deploy widgets` already includes the third subshell — running it from -the host root pushes all three widgets in sequence. +Outputs to `apps/widget-whatsapp/dist/`. Deploy by rsyncing `dist/*` +into `~/vojo/widgets/whatsapp/` on the production host (Caddy serves +this via the `widgets.vojo.chat` block) — the VSCode task +`Deploy widgets` does all three widgets. -## Capacitor (Android) +## Server-side requirements -`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing -TG / Discord widgets — no extra entry needed for WhatsApp. +1. The widget static files at `widgets.vojo.chat/whatsapp/` (Caddy + `handle_path /whatsapp/*` block). +2. The bridge provisioning API exposed at the URL configured in + config.json `experience.provisioningUrl`. Caddy block inside the + `vojo.chat` site, next to the telegram one: -## Hosting (server-side) + ``` + handle /_provision/whatsapp/* { + uri replace /_provision/whatsapp /_matrix/provision + reverse_proxy whatsapp-bridge:29318 # host:port from appservice.address + } + ``` -Same Caddy `widgets.vojo.chat` block as the other widgets — add a third -`handle_path /whatsapp/* { … }` block alongside `/telegram/*` and -`/discord/*`. Then `mkdir -p ~/vojo/widgets/whatsapp` on the server, run -the deploy task, and verify with -`curl -I https://widgets.vojo.chat/whatsapp/index.html`. + Path-scoped on purpose: the same bridge listener serves the appservice + transaction endpoints (`/_matrix/app/*`), which must stay internal. +3. `provisioning.shared_secret` in the bridge config must NOT be + `disable` (a ≥16-char secret enables the API; the widget never sees + the secret — it authenticates with per-user OpenID tokens). -## Source-of-truth pointers +## Updating the production /config.json -- mautrix-whatsapp connector: -- mautrix-whatsapp connector (post-login session events): - -- whatsmeow QR format: (`makeQRData`) -- whatsmeow pairing-code: (`PairPhone`) -- bridgev2 commands layer (shared with mautrix-telegram): - +```json +"experience": { + "type": "matrix-widget", + "url": "https://widgets.vojo.chat/whatsapp/index.html", + "provisioningUrl": "https://vojo.chat/_provision/whatsapp", + "capabilities": ["vojo.openid"] +} +``` -The dialect file `src/bridge-protocol/dialects/bridgev2_v0264.ts` has -inline upstream pointers per regex; when the bridge image is upgraded, -spot-check those pointers and either confirm the wording is still valid -or drop a sibling dialect file with new regexes. +## Capability contract + +The widget requests **no** MSC2762 capabilities — the handshake replies +with only `org.matrix.msc4039.download_file` (avatar thumbnails). The +privileged surfaces are: + +- MSC1960 `get_openid` — granted by `BotWidgetDriver.askOpenID` iff + config.json declares `"capabilities": ["vojo.openid"]` for this bot; +- `io.vojo.bot-widget` side-channel verbs `open-external-url` and + `open-matrix-to` (origin-pinned and validated host-side in + `BotWidgetEmbed.onWidgetMessage`). diff --git a/apps/widget-whatsapp/src/App.tsx b/apps/widget-whatsapp/src/App.tsx index 4f4b1666..6db01982 100644 --- a/apps/widget-whatsapp/src/App.tsx +++ b/apps/widget-whatsapp/src/App.tsx @@ -1,724 +1,79 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; -import type { Dispatch } 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'; +// Widget shell. Three surfaces driven by GET /v3/whoami: +// +// boot → handshake + first whoami round-trip +// disconnected → login action cards + the active login-flow form +// connected → the contacts list (single main surface); profile and +// session actions live in the Account modal behind the +// icon button next to the status pill +// +// The bridge is driven exclusively over its provisioning HTTP API +// (provisioning.ts) authenticated with MSC1960 OpenID credentials from the +// host. There is no transcript pane and no bot text-command parsing — every +// action reports its status inline, next to the control that fired it. +// +// Visual canon: «Боты» mockup at docs/design/new-direct-messages-design/ +// project/stream-v2-dawn.jsx (BotsDesktop) — Dawn palette, fleet-violet +// accent, friendly product cards, no terminal styling. WhatsApp-only +// deviation: the About card and modal carry the Meta-ToS risk disclosure +// (amber warn styling + triangle icon). + +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import type { WidgetBootstrap } from './bootstrap'; -import { WidgetApi, type RoomEvent } from './widget-api'; -import { createT, type T, type StringKey } from './i18n'; -import { parseEvent } from './bridge-protocol/parser'; +import { WidgetApi } from './widget-api'; +import { ProvisioningClient, type WhoamiLogin } from './provisioning'; +import { describeApiError } from './errors'; +import { createT, type T } from './i18n'; +import { CodePanel, PhoneForm, QrPanel, useLoginFlow } from './login'; +import { Contacts } from './contacts'; +import { useFirstAvatar } from './avatars'; import { - hydrateFromTimeline, - initialLoginState, - loginReducer, - type HydrateInput, - type LoginAction, - type LoginErrorFlag, - type LoginState, -} from './state'; - -// Visual canon mirrors the Telegram and Discord widgets — Dawn palette, -// fleet-violet accent, monospace handles. We DO NOT adopt WhatsApp's -// signature green; the panel is meant to read as a coherent continuation -// of the host UI ("Vojo style"), not a WhatsApp clone. - -type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error'; - -type TranscriptLine = { - id: string; - ts: number; - kind: TranscriptKind; - text: string; -}; + Avatar, + BackIcon, + CommandCard, + LogoutIcon, + Notice, + PhoneIcon, + QrIcon, + RefreshIcon, + Spinner, + StatusPill, + UserIcon, + WarningIcon, +} from './ui'; type Props = { bootstrap: WidgetBootstrap; - // The WidgetApi is constructed in main.tsx synchronously, BEFORE React's - // first render — see widget-telegram for the cached-bundle race rationale. + // Constructed in main.tsx BEFORE the first render so its postMessage + // listener is attached before the host's ClientWidgetApi fires the + // capabilities request on iframe `load`. api: WidgetApi; }; -const TRANSCRIPT_MAX = 200; +type Phase = + | { kind: 'boot' } + | { kind: 'error'; message: string } + | { kind: 'disconnected' } + | { kind: 'connected'; login: WhoamiLogin }; -// 8 s — between submit and bot reply on the phone form. Applies only to -// the pairing-code phone form because there's no separate "code" form -// like Telegram has (WhatsApp's pairing-code is shown by the bridge, -// not typed by the user). -const STILL_WAITING_DELAY_MS = 8_000; +type NoticeState = { tone: 'error' | 'info'; text: string } | null; -// Inline SVG refresh icon — shared with TG / Discord widgets. -const RefreshIcon = () => ( - -); - -// Smartphone outline — leads the «Войти по номеру» card. Shared shape -// across TG (text-login) and WhatsApp (pairing-code login) since both -// flows boil down to «вы вводите номер с телефона». -const PhoneIcon = () => ( - -); - -// Three QR finder squares + a few module dots — leads every QR-login -// card. The finder pattern is the strongest «this is QR» visual cue; -// no need to draw a full code. -const QrIcon = () => ( - -); - -// Sign-out arrow leaving an open box — leads the destructive logout -// card. Open right side conveys «out of the session». Picks up the -// rose tint via `.command-card.danger` cascade only on the title; -// the lead icon stays muted so the rose stays a single accent. -const LogoutIcon = () => ( - -); - -// Triangle warning glyph — leads the WhatsApp-only AboutCard (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. -const WarningIcon = () => ( - -); - -// Linkifier — same heuristic as TG / Discord widgets. -const URL_RE = /https?:\/\/[^\s)]+/g; - -// WA-only: defence-in-depth scrub of any whatsmeow QR payload from text -// before appending to the transcript. Today the bridge only emits the -// payload via `m.image` (which we explicitly route to a generic -// «QR-код обновлён» diag, never verbatim), but if a future bridge -// revision starts echoing the payload into m.notice — say for a -// chat-fallback debug surface — the existing transcript append would -// store the adv-secret segment in the DOM. The adv-secret IS the live -// cryptographic material the phone signs to prove possession; even one -// accidental transcript line would survive page reloads via the hydrate -// replay. Keep this scrubber in sync with the parser's WA_QR_PAYLOAD_RE -// (bridgev2_v0264.ts) — they describe the same upstream shape. -// -// Anchoring rationale (frontend review #1): the upstream `makeQRData` -// always prefixes the first field with `@` (e.g. -// `2@AbCd...` — the leading digit is the protocol generation, currently -// `2`). The ref field is also always at least 16 chars long, and the -// three trailing base64 fields hover around 24-44 chars each. We -// therefore require: -// - leading `\d@` to anchor on the unmistakable WA-protocol prefix, -// - each segment to be at least 8 chars long. -// That's narrow enough that an unrelated body matching the shape by -// accident is implausible, while still tolerant of future protocol -// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.g. -// «error: a,b,c,d in field» — without the digit prefix and the segment -// length floor, the old regex would clobber that. -const WA_QR_PAYLOAD_GLOBAL_RE = /\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g; -const scrubLoginSecret = (body: string): string => - body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]'); - -const formatTime = (ts: number): string => { - const d = new Date(ts); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - return `${hh}:${mm}:${ss}`; +const loginHandle = (login: WhoamiLogin): string => { + const profile = login.profile ?? {}; + // WhatsApp has no usernames — RemoteProfile carries phone (+ prefixed by + // the connector) and sometimes a business name. + if (profile.phone) return profile.phone.startsWith('+') ? profile.phone : `+${profile.phone}`; + if (login.name) return login.name; + return login.id; }; -// Build transcript children from a body string with naive URL linkification. -// Plain markdown formatting from the bot (backticks, asterisks) shows -// literal — the upstream wording isn't load-bearing on rendering, and -// re-implementing markdown here is a worse trade-off than rendering -// raw. -const renderBody = (body: string): ComponentChildren => { - const out: ComponentChildren[] = []; - let lastIndex = 0; - for (const match of body.matchAll(URL_RE)) { - const idx = match.index ?? 0; - if (idx > lastIndex) out.push(body.slice(lastIndex, idx)); - out.push( - - {match[0]} - - ); - lastIndex = idx + match[0].length; - } - if (lastIndex < body.length) out.push(body.slice(lastIndex)); - return out.length === 0 ? body : out; -}; +// --- About card + modal ------------------------------------------------------- +// WhatsApp-specific: the About surface doubles as the Meta-ToS risk +// disclosure. The card carries the amber warn styling + triangle icon so the +// «риски» half of the hybrid description is visible before opening; the +// modal leads with the amber callout. This copy is a legal requirement — +// keep it when restyling. -// WhatsApp's pairing-code flow only takes the phone number as user -// input — the code itself is shown by the bridge, not typed by the -// user, so user-typed values that hit the transcript never need -// masking. Telegram-style mask helpers were intentionally not ported. - -const localizeError = (err: LoginErrorFlag, t: T): string => { - switch (err.kind) { - case 'login_failed': - return t('auth-error.login-failed', { reason: err.reason ?? '' }); - case 'invalid_value': - return t('auth-error.invalid-value', { reason: err.reason ?? '' }); - case 'submit_failed': - return t('auth-error.submit-failed', { reason: err.reason ?? '' }); - case 'login_in_progress': - return t('auth-error.login-in-progress'); - case 'max_logins': - return t('auth-error.max-logins', { limit: String(err.limit ?? '?') }); - case 'unknown_command': - return t('auth-error.unknown-command'); - case 'start_failed': - return t('auth-error.start-failed', { reason: err.reason ?? '' }); - case 'prepare_failed': - return t('auth-error.prepare-failed', { reason: err.reason ?? '' }); - case 'external_logout': { - const subKey: StringKey = - err.reason === 'another_device' - ? 'auth-error.external-logout.another-device' - : err.reason === 'phone_logged_out' - ? 'auth-error.external-logout.phone-logged-out' - : 'auth-error.external-logout.unknown'; - return t(subKey); - } - default: { - const exhaustive: never = err; - return String(exhaustive); - } - } -}; - -// Centralised tone affordance: red `.auth-card-error` vs amber -// `.auth-card-warn`. WhatsApp-side soft errors (rate-limited, retry-able -// pairing failure) are warnings; hard validation errors are red. -// `external_logout` is special — it's surfaced as a top-level banner, -// not a form-side error tone. -const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => { - if (err.kind === 'submit_failed') return 'warn'; - if (err.kind === 'login_in_progress') return 'warn'; - return 'error'; -}; - -// -------------------------------------------------------------------------- -// Phone form (pairing-code flow only) -// -------------------------------------------------------------------------- - -type FormProps = { - state: LoginState; - t: T; - dispatch: Dispatch; - send: (body: string) => Promise; - sendCancel: () => Promise; - // Phone-form cooldown plumbing — lifted to App so it survives the form's - // unmount on Cancel + remount on a fresh «Войти по коду» click. - phoneCooldownEnd: number | null; - setPhoneCooldownEnd: (ts: number | null) => void; -}; - -// Phone-submit cooldown — WhatsApp throttles repeated pairing-code -// requests at the connector level (`Rate limited by WhatsApp`). -// 60 s matches the rough recovery window that observed flood replies -// stop firing after. -const PHONE_COOLDOWN_MS = 60_000; - -// Minimum digits required before we surface an «invalid number» hint. -// Below this the user is still typing the country prefix and the -// formatter has nothing useful to validate. -const PHONE_MIN_DIGITS_FOR_VALIDATION = 7; - -// Strip every character that isn't `+` or a digit, then guarantee a -// single leading `+` — whatsmeow's PairPhone validator fires -// `PHONE_NUMBER_NOT_INTERNATIONAL` without it. -const phoneToE164 = (raw: string): string => { - const cleaned = raw.replace(/[^\d+]/g, ''); - if (cleaned.length === 0) return ''; - return cleaned.startsWith('+') ? cleaned : `+${cleaned}`; -}; - -// AsYouType is stateful — call `.input()` on a fresh instance per render -// so paste / mid-string edits don't desync the formatter buffer from the -// React state. -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-symbol emoji («RU» → 🇷🇺). -// Windows Chrome doesn't ship the flag glyphs and falls back to the -// two-letter code rendered as plain letters — still readable. -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); -}; - -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); - // The deps array is intentionally controlled: re-run only when the - // future timestamp itself changes. `compute` would otherwise change - // every render and re-trigger setInterval. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [until]); - return seconds; -}; - -const useStillWaitingHint = (deps: ReadonlyArray): boolean => { - const [show, setShow] = useState(false); - useEffect(() => { - setShow(false); - const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS); - return () => window.clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - return show; -}; - -const PhoneForm = ({ - state, - t, - dispatch, - send, - sendCancel, - phoneCooldownEnd, - setPhoneCooldownEnd, -}: FormProps) => { - const [value, setValue] = useState(''); - // Country comes straight from the AsYouType call inside `onInput` so - // the formatter runs once per keystroke (instead of once for - // formatting and once more in a useMemo for the flag). - const [country, setCountry] = useState(undefined); - const [submitting, setSubmitting] = useState(false); - const inputRef = useRef(null); - const stillWaiting = useStillWaitingHint([submitting]); - const cooldownSeconds = useCooldownSeconds(phoneCooldownEnd); - const inCooldown = cooldownSeconds > 0; - const error = state.kind === 'awaiting_phone' ? state.lastError : undefined; - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - // Wire-format value (no spaces, single leading `+`) — what we send to - // the bridge, and what libphonenumber-js validates. Both helpers are - // cheap (regex on a 10-char string, length-table lookup) and safe to - // recompute every render without memoisation. - const e164 = phoneToE164(value); - const digitsCount = e164.replace('+', '').length; - const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION; - // `isValidPhoneNumber` is a soft hint, not a hard gate: stale `/min` - // metadata can reject freshly-allocated mobile pools, and whatsmeow's - // own validator on the bridge side is authoritative. Match the - // Stripe / Auth0 / WhatsApp Web warn-don't-block pattern. - const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164); - - const onSubmit = async (event: Event) => { - event.preventDefault(); - if (!e164 || submitting || inCooldown || !hasEnoughDigits) return; - setSubmitting(true); - dispatch({ kind: 'submit_phone' }); - try { - // Strip visual spaces AsYouType inserted before sending — whatsmeow - // PairPhone wants raw E.164. - await send(e164); - // Cooldown locks retries ONLY after the Matrix transport accepted - // the message. If `await send` threw (network down, capability - // race), no pairing-code request was attempted at the WhatsApp - // side — punishing the user for an issue they can fix by clicking - // again would be wrong. The cooldown is also cleared by the App - // when the bridge replies with `invalid_value` (malformed phone - // — bridgev2 rejects before WhatsApp dispatch). - setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS); - } catch { - /* transcript carries the diagnostic; form stays open for retry */ - } finally { - setSubmitting(false); - } - }; - - const tone = error ? errorTone(error) : undefined; - const submitDisabled = submitting || inCooldown || !hasEnoughDigits; - const submitLabel = inCooldown - ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) }) - : t('auth-card.phone.submit'); - const flagEmoji = countryToFlagEmoji(country); - - return ( -
-
{t('auth-card.phone.title')}
- -
-
- {flagEmoji ? ( - - ) : null} - { - // Re-format on every keystroke via a fresh AsYouType. Strips - // non-digit / non-`+` chars (so a paste of «+1 (213) 373-4253» - // or « +1…» normalises), auto-prepends `+` if missing - // (whatsmeow PairPhone rejects otherwise), and groups digits - // per country convention. Caret jumps to end on re-format — - // acceptable for left-to-right phone entry. Country is read - // from the same formatter call so the flag updates without - // a second AsYouType pass. - const raw = (e.currentTarget as HTMLInputElement).value; - const next = formatPhoneInput(raw); - setValue(next.formatted); - setCountry(next.country); - }} - disabled={submitting} - /> -
- - -
-
{t('auth-card.phone.hint')}
- {showInvalidHint && !error ? ( -
{t('auth-card.phone.invalid')}
- ) : null} - {error ? ( -
- {localizeError(error, t)} -
- ) : null} - {submitting && stillWaiting ? ( -
{t('auth-card.waiting-hint')}
- ) : null} -
- ); -}; - -// -------------------------------------------------------------------------- -// QR panel -// -------------------------------------------------------------------------- - -// Whatsmeow's QR rotation schedule (verified upstream pair.go::qrIntervals): -// first QR: 60 s -// QRs 2..6: 20 s each → 5 × 20 s = 100 s -// Total active window: 160 s = 2 min 40 s. After the last QR, the -// bridge surfaces `Login failed: Entering code or scanning QR timed -// out. Please try again.` We render a soft timeout countdown (3 min, -// matching HYDRATE_FRESHNESS_MS so a reload past the panel-expiry -// also can't restore the dead flow), NOT a hard kill — when it expires -// the panel switches to a recovery hint. -const QR_TIMEOUT_MS = 3 * 60 * 1000; - -// Error-correction level M — same trade-off as TG/Discord (more -// resilient to camera glare than L, smaller modules than Q). -// typeNumber=0 auto-picks the smallest version that fits the payload; -// for a whatsmeow handshake (~140 chars) this lands around version 7-8. -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 elements inside an SVG. We deliberately -// avoid `dangerouslySetInnerHTML` and any external QR-rendering service: -// the whatsmeow handshake IS the login secret (the adv-secret field is -// what the phone signs to prove possession), so it must never leave the -// iframe and must never reach a stringified-HTML path that bypasses -// Preact's escaping. -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( - - ); - } - } - return ( - - {rects} - - ); -}; - -type QrPanelProps = { - state: { - kind: 'awaiting_qr_scan'; - qrData: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - }; - t: T; - sendCancel: () => Promise; -}; - -const QrPanel = ({ state, t, sendCancel }: QrPanelProps) => { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); - - const matrix = useMemo(() => buildQrModules(state.qrData), [state.qrData]); - const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0; - const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000)); - const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0; - - return ( -
-
{t('auth-card.qr.title')}
-
{t('auth-card.qr.hint')}
-
- {matrix ? ( - - ) : ( -
- - {t('auth-card.qr.preparing')} -
- )} -
- {!expired ? ( -
- {t('auth-card.qr.countdown', { - minutes: String(Math.floor(remainingSeconds / 60)), - seconds: String(remainingSeconds % 60).padStart(2, '0'), - })} -
- ) : ( -
{t('auth-card.qr.expired')}
- )} -
    -
  1. {t('auth-card.qr.step-1')}
  2. -
  3. {t('auth-card.qr.step-2')}
  4. -
  5. {t('auth-card.qr.step-3')}
  6. -
- {state.lastError ? ( -
- {localizeError(state.lastError, t)} -
- ) : null} -
- -
-
- ); -}; - -// -------------------------------------------------------------------------- -// Pairing-code panel -// -------------------------------------------------------------------------- - -// Pairing code server-side validity at WhatsApp's gateway is roughly the -// same as QR (~3 minutes — verified empirically against whatsmeow -// PairPhone behaviour, no explicit constant in the lib). We share the -// same 3-min window as QR_TIMEOUT_MS / HYDRATE_FRESHNESS_MS so reload -// past expiry can't restore a dead flow. -const PAIRING_CODE_TIMEOUT_MS = 3 * 60 * 1000; - -type PairingCodePanelProps = { - state: { - kind: 'pairing_code_shown'; - code: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - }; - t: T; - sendCancel: () => Promise; -}; - -const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); - - const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0; - const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)); - const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0; - - return ( -
-
{t('auth-card.pairing-code.title')}
-
{t('auth-card.pairing-code.hint')}
-
- {state.code ? ( - // is the semantic element for "result of a process" - // (HTML form-results spec); screenreaders read the digits from - // its text content. The supplemental purpose-description is - // attached via aria-describedby on a hidden sibling so it - // doesn't override the digits in screenreader output (frontend - // review #2: a `
` with both aria-label and visible text - // can hide the digits behind the label in NVDA/JAWS). - // user-select: all on the text element keeps one-tap copy - // working on touch devices. - <> - - {state.code} - - - {t('auth-card.pairing-code.aria')} - - - ) : ( -
- - {t('auth-card.pairing-code.preparing')} -
- )} -
- {!expired ? ( -
- {t('auth-card.pairing-code.countdown', { - minutes: String(Math.floor(remainingSeconds / 60)), - seconds: String(remainingSeconds % 60).padStart(2, '0'), - })} -
- ) : ( -
{t('auth-card.pairing-code.expired')}
- )} -
    -
  1. {t('auth-card.pairing-code.step-1')}
  2. -
  3. {t('auth-card.pairing-code.step-2')}
  4. -
  5. {t('auth-card.pairing-code.step-3')}
  6. -
  7. {t('auth-card.pairing-code.step-4')}
  8. -
- {state.lastError ? ( -
- {localizeError(state.lastError, t)} -
- ) : null} -
- -
-
- ); -}; - -// -------------------------------------------------------------------------- -// About card + modal -// -------------------------------------------------------------------------- - -type AboutCardProps = { - t: T; - onOpen: () => void; -}; - -// WhatsApp-only: AboutCard carries the `warn` modifier so the amber -// border + amber-tinted name signal that the modal behind it includes -// the Meta-ToS risk disclosure. The leading icon is the triangle -// WarningIcon (not the info-circle used by TG / Discord) for the -// same reason — the hybrid card description («о работе и рисках») -// stays believable when the icon previews the «risks» half. -const AboutCard = ({ t, onOpen }: AboutCardProps) => ( - -); - -type AboutModalProps = { - t: T; - onClose: () => void; -}; +type AboutModalProps = { t: T; onClose: () => void }; const AboutModal = ({ t, onClose }: AboutModalProps) => { useEffect(() => { @@ -752,13 +107,6 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => {
- {/* WhatsApp-specific Meta-ToS account-ban risk disclosure. - * Lives at the top of the About modal as an amber-tinted - * block — same content the dedicated WarningCard used to - * carry on the disconnected screen, folded in here so the - * About card is the single info entry point (matches the - * TG / Discord shape; the amber block carries the «risks» - * half of the hybrid card description). */}
+ ); + } + + return
; } diff --git a/apps/widget-whatsapp/src/avatars.ts b/apps/widget-whatsapp/src/avatars.ts new file mode 100644 index 00000000..d58a59de --- /dev/null +++ b/apps/widget-whatsapp/src/avatars.ts @@ -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>(); + +const MAX_CONCURRENT_DOWNLOADS = 4; +let active = 0; +const waiters: Array<() => void> = []; + +const acquireSlot = async (): Promise => { + if (active >= MAX_CONCURRENT_DOWNLOADS) { + await new Promise((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 => { + 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 => { + 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(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 | null => { + const [url, setUrl] = useState(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; +}; diff --git a/apps/widget-whatsapp/src/bootstrap.ts b/apps/widget-whatsapp/src/bootstrap.ts index 74ec1293..952a2c1b 100644 --- a/apps/widget-whatsapp/src/bootstrap.ts +++ b/apps/widget-whatsapp/src/bootstrap.ts @@ -1,5 +1,5 @@ -// Parse the URL params the host appends when loading experience.url. -// Source of truth on the host side: +// Parse the URL params the bot widget host appends when loading +// experience.url. Source of truth on the host side: // src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl). // Keep this in sync if the host adds params. @@ -11,14 +11,12 @@ export type WidgetBootstrap = { userId: string; botId: string; botMxid: string; - /** Bridge command prefix (e.g. `!wa`). Always non-empty — the host - * validator (catalog.ts) defaults missing values to `!tg` and rejects - * malformed overrides. The widget prepends ` ` to every - * outbound command and form-field value (bridgev2/queue.go:118 strips - * exactly `prefix+" "`). For mautrix-whatsapp the operator must set - * `commandPrefix: "!wa"` in /config.json — connector.go ships - * `DefaultCommandPrefix: "!wa"`. */ - commandPrefix: string; + /** Base URL of the bridge provisioning HTTP API (bridgev2 + * `/_matrix/provision` mount behind the reverse proxy), e.g. + * `https://vojo.chat/_provision/whatsapp`. Empty string when the host + * config hasn't exposed it — the App renders a config-required notice + * instead of booting the transport. */ + provisioningUrl: string; theme: 'light' | 'dark'; clientLanguage: string; }; @@ -27,7 +25,7 @@ export type BootstrapResult = | { ok: true; bootstrap: WidgetBootstrap } | { ok: false; missing: string[] }; -const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const; +const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const; export const readBootstrap = (search: string): BootstrapResult => { const params = new URLSearchParams(search); @@ -46,6 +44,27 @@ export const readBootstrap = (search: string): BootstrapResult => { return { ok: false, missing: ['parentUrl'] }; } + // The host validator (catalog.ts normalizeProvisioningUrl) already + // enforces https + no embedded credentials; re-parse defensively anyway + // because this is the widget's fetch target. Malformed → '' → the App + // shows the config-required notice rather than fetching a garbage URL. + let provisioningUrl = ''; + const rawProvisioning = get('provisioningUrl').trim(); + if (rawProvisioning) { + try { + const parsed = new URL(rawProvisioning); + if ( + !parsed.username && + !parsed.password && + (parsed.protocol === 'https:' || (import.meta.env.DEV && parsed.protocol === 'http:')) + ) { + provisioningUrl = parsed.toString().replace(/\/+$/, ''); + } + } catch { + /* keep '' */ + } + } + const themeRaw = get('theme'); const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light'; @@ -59,7 +78,7 @@ export const readBootstrap = (search: string): BootstrapResult => { userId: get('userId'), botId: get('botId'), botMxid: get('botMxid'), - commandPrefix: get('commandPrefix'), + provisioningUrl, theme, clientLanguage: get('clientLanguage'), }, diff --git a/apps/widget-whatsapp/src/bridge-protocol/dialects/bridgev2_v0264.ts b/apps/widget-whatsapp/src/bridge-protocol/dialects/bridgev2_v0264.ts deleted file mode 100644 index 25270f55..00000000 --- a/apps/widget-whatsapp/src/bridge-protocol/dialects/bridgev2_v0264.ts +++ /dev/null @@ -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 ") -// - list-logins reply: user.go:185-190 ("\n* `` () - ``") -// - 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 = "+" -// - 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: "" -// -// 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 -// `* \`\` () - \`\``. -// -// For WhatsApp: -// = JID-derived login id (digits, possibly digits.0) -// = "+" (e.g. "+12345678901") -// = state string ("CONNECTED" etc) -// -// Greedy `(.+)` capture for name backtracks to the LAST `)` before -// ` - ``` — paranoid against future RemoteName drift even though -// WhatsApp's RemoteName is currently always `+`. -const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm; - -// Phone prompt — bridgev2/commands/login.go composes -// `Please enter your \n`. 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 +`. -// 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 « 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 `XXXX-XXXX` 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 - // (` * `` `) 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: `,,,`. -// 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 => - 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@`; 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 ?? ''}` - ); - } - } -} - -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); -} diff --git a/apps/widget-whatsapp/src/bridge-protocol/parser.ts b/apps/widget-whatsapp/src/bridge-protocol/parser.ts deleted file mode 100644 index efe4ecb6..00000000 --- a/apps/widget-whatsapp/src/bridge-protocol/parser.ts +++ /dev/null @@ -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); diff --git a/apps/widget-whatsapp/src/bridge-protocol/types.ts b/apps/widget-whatsapp/src/bridge-protocol/types.ts deleted file mode 100644 index 29c9295e..00000000 --- a/apps/widget-whatsapp/src/bridge-protocol/types.ts +++ /dev/null @@ -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 -// `,,,` (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 +` — 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 `` 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: `). 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: ` (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' }; diff --git a/apps/widget-whatsapp/src/contacts.tsx b/apps/widget-whatsapp/src/contacts.tsx new file mode 100644 index 00000000..fdebb9d0 --- /dev/null +++ b/apps/widget-whatsapp/src/contacts.tsx @@ -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 ( +
+ +
+
+ {contact.name || phoneText || contact.id} +
+ {phoneText && contact.name ? ( +
+ {phoneText} +
+ ) : null} +
+ +
+ ); +}; + +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({ kind: 'loading' }); + const [query, setQuery] = useState(''); + const [probe, setProbe] = useState(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(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(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) => ( + 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 ( +
+
{t('contacts.probe-self')}
+
+ ); + } + return ( +
+
{t('contacts.probe-found')}
+ {renderRow(activeProbe.contact, `probe:${activeProbe.contact.id}`)} +
+ ); + } + if (activeProbe?.status === 'not-found') { + return ( +
+
+ {t('contacts.probe-not-found', { handle: activeProbe.identifier.display })} +
+
+ ); + } + const checking = activeProbe?.status === 'checking'; + return ( + + ); + }; + + return ( +
+
{ + 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(); + } + }} + > + + setQuery((e.currentTarget as HTMLInputElement).value)} + /> +
+ + {!query && list.kind === 'ready' ?
{t('contacts.hint')}
: null} + + {renderProbeArea()} + + {list.kind === 'loading' ? ( +
+ + {t('contacts.loading')} +
+ ) : null} + + {list.kind === 'error' ? ( +
+ {list.message || t('contacts.error')} + +
+ ) : null} + + {list.kind === 'ready' ? ( + <> + {filtered.length > 0 ? ( +
{filtered.map((c) => renderRow(c, c.id))}
+ ) : ( +
+ {query.trim() ? t('contacts.empty-filtered') : t('contacts.empty')} +
+ )} + + ) : null} +
+ ); +}; diff --git a/apps/widget-whatsapp/src/errors.ts b/apps/widget-whatsapp/src/errors.ts new file mode 100644 index 00000000..ba31e811 --- /dev/null +++ b/apps/widget-whatsapp/src/errors.ts @@ -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) }); +}; diff --git a/apps/widget-whatsapp/src/i18n/en.ts b/apps/widget-whatsapp/src/i18n/en.ts index 2285b9e3..39412741 100644 --- a/apps/widget-whatsapp/src/i18n/en.ts +++ b/apps/widget-whatsapp/src/i18n/en.ts @@ -1,32 +1,36 @@ -// English fallback. Mirror the RU key set; `Record` -// enforces every RU key has an EN counterpart at compile time. +// English fallback. Mirror the RU key set; `Record` enforces +// that every RU key has an EN counterpart at compile time. import type { StringKey } from './ru'; export const EN: Record = { - 'status.unknown': 'Checking status…', - 'status.disconnected': 'WhatsApp not linked', - 'status.connected': 'WhatsApp linked', - 'status.connected-as': 'WhatsApp linked as {handle}', - 'status.logging-out': 'Signing out…', - 'status.qr-verifying': 'Verifying sign-in…', - 'status.pairing-verifying': 'Verifying sign-in…', + // --- Status pill --------------------------------------------------------- + 'status.checking': 'Checking status…', + 'status.disconnected': 'WhatsApp is not linked', + 'status.connected-as': 'Linked as {handle}', + + // --- Action cards ---------------------------------------------------------- + 'card.login.name': 'Sign in by phone number', + 'card.login.desc': 'Enter your number and get an 8-character code for WhatsApp', 'card.login-qr.name': 'Sign in with QR code', 'card.login-qr.desc': 'Scan a QR code from the WhatsApp mobile app', - 'card.login-pairing.name': 'Sign in by phone number', - 'card.login-pairing.desc': 'Enter your number and get an 8-character code for WhatsApp', - 'card.refresh.aria': 'Refresh status', - 'card.refresh.label': 'Refresh status', 'card.refresh.name': 'Refresh status', 'card.refresh.desc': 'Re-check whether WhatsApp is linked', 'card.refresh.in-flight': 'Checking…', + 'card.logout.name': 'Sign out of WhatsApp', + 'card.logout.desc': 'End the session on this account', + 'card.logout.confirm-prompt': 'Sign out for real?', + 'card.logout.confirm-yes': 'Sign out', + 'card.logout.confirm-no': 'Cancel', + + // --- About panel ----------------------------------------------------------- + 'card.about.name': 'How the WhatsApp bot works', + 'card.about.desc': 'How it works and the risks — tap to read', 'warning.title': 'Important before linking WhatsApp', 'warning.body-1': 'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, 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-url': 'https://www.whatsapp.com/legal/terms-of-service', - 'card.about.name': 'How the WhatsApp bot works', - 'card.about.desc': 'How it works and the risks — tap to read', 'about.title': 'About the WhatsApp bot', 'about.body-1': 'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in 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 = { 'about.github-label': 'The bridge source code is public on GitHub:', 'about.github-url': 'https://github.com/mautrix/whatsapp', 'about.body-4': - 'You can revoke access at any time — either with the “Sign out of WhatsApp” button here, or inside WhatsApp itself under Settings → Linked devices → Log out of all devices.', + 'You can revoke access at any time — with the “Sign out of WhatsApp” button here, or in WhatsApp itself under Settings → Linked devices → Log out from all devices.', 'about.close': 'Close', - 'about.aria-close': 'Close “About this bot”', - 'auth-card.phone.title': 'Sign in with a pairing code', + 'about.aria-close': 'Close “About the bot”', + + // --- Phone form (pairing-code flow) ----------------------------------------- + 'auth-card.phone.title': 'Sign in by phone number', 'auth-card.phone.label': 'Phone number', - 'auth-card.phone.placeholder': '+15551234567', + 'auth-card.phone.placeholder': '+79991234567', 'auth-card.phone.hint': - 'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.', - 'auth-card.phone.submit': 'Get code', - 'auth-card.phone.cooldown': 'Retry in {seconds}s', - 'auth-card.phone.invalid': "This doesn't look like a complete international phone number.", + 'Enter your number with the country code. WhatsApp will then create an 8-character code to enter in the app.', + 'auth-card.phone.submit': 'Get the code', + 'auth-card.phone.cooldown': 'Retry in {seconds} s', + 'auth-card.phone.invalid': 'The number looks incomplete or mistyped.', + + // --- Pairing-code panel -------------------------------------------------- 'auth-card.pairing-code.title': 'Enter this code in WhatsApp', 'auth-card.pairing-code.hint': 'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.', - 'auth-card.pairing-code.preparing': 'Preparing the code…', - 'auth-card.pairing-code.aria': - 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.', - 'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}', - 'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.', + 'auth-card.pairing-code.aria': 'WhatsApp sign-in code. Enter it in the app on your phone.', + 'auth-card.pairing-code.countdown': '{minutes}:{seconds} left to enter the code', + 'auth-card.pairing-code.expired': 'The sign-in window expired. Press “Cancel” and try again.', 'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.', 'auth-card.pairing-code.step-2': 'Go to Settings → Linked devices.', - 'auth-card.pairing-code.step-3': 'Tap Link a device → Link with phone number.', - 'auth-card.pairing-code.step-4': 'Enter this code and confirm sign-in on your phone.', - 'auth-card.qr.title': 'QR code sign-in', + 'auth-card.pairing-code.step-3': 'Tap “Link a device → Link with phone number”.', + 'auth-card.pairing-code.step-4': 'Enter this code and confirm the sign-in on your phone.', + 'auth-card.pairing-code.copy': 'Copy the code', + 'auth-card.pairing-code.copied': 'Copied', + + // --- Shared form chrome ------------------------------------------------ + 'auth-card.cancel': 'Cancel', + 'auth-card.waiting-hint': 'The bridge is still thinking… replies can take up to 30 seconds.', + + // --- QR panel ------------------------------------------------------------ + 'auth-card.qr.title': 'Sign in with QR code', 'auth-card.qr.hint': 'Open WhatsApp on your phone and scan this QR code.', - 'auth-card.qr.preparing': 'Preparing QR code…', - 'auth-card.qr.aria': 'QR code for WhatsApp sign-in. Scan it with your phone.', - 'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}', - 'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.', + 'auth-card.qr.preparing': 'Preparing the QR code…', + 'auth-card.qr.aria': 'QR code for signing in to WhatsApp. Scan it with your phone.', + 'auth-card.qr.countdown': '{minutes}:{seconds} left to scan', + 'auth-card.qr.expired': 'The sign-in window expired. Press “Cancel” and try again.', 'auth-card.qr.step-1': 'Open WhatsApp on your phone.', 'auth-card.qr.step-2': 'Go to Settings → Linked devices.', - 'auth-card.qr.step-3': 'Tap Link a device and scan the QR code.', - 'auth-card.cancel': 'Cancel', - 'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.', - 'auth-error.login-failed': 'Sign-in failed: {reason}', - 'auth-error.invalid-value': 'Value not accepted: {reason}', - 'auth-error.submit-failed': 'WhatsApp refused the input: {reason}', - 'auth-error.start-failed': 'Failed to start sign-in: {reason}', - 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', - 'auth-error.login-in-progress': - 'The bot already has another sign-in flow open. Click Cancel and retry.', - 'auth-error.max-logins': 'Login limit reached ({limit}). Sign out of an existing account first.', - 'auth-error.unknown-command': - 'The bot does not recognise this command — check the prefix in config.json.', - 'auth-error.external-logout.another-device': - 'WhatsApp unlinked this device from another device. Sign in again.', - 'auth-error.external-logout.phone-logged-out': - 'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.', - 'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.', - 'card.logout.name': 'Sign out of WhatsApp', - 'card.logout.desc': 'End the session for this account', - 'card.logout.confirm-prompt': 'Sign out for real?', - 'card.logout.confirm-yes': 'Sign out', - 'card.logout.confirm-no': 'Cancel', - 'card.logout.gated': 'Session identifier still loading — give it a moment.', - 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', - 'diag.ready': 'Ready to send commands.', - 'diag.checking-status': 'Checking connection status…', - 'diag.send-failed': 'send failed: {message}', - 'diag.history-marker': '─── history ───', - 'diag.history-unavailable': 'Could not read history — re-checking status.', - 'diag.qr-issued': 'QR code refreshed.', - 'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.', - 'diag.pairing-code-issued': 'Pairing code issued.', - 'diag.connection-warning': '{text}', - 'diag.external-logout': 'WhatsApp dropped the session — sign-in needed.', - 'bootstrap.failed': 'Widget failed to start', - 'bootstrap.missing-params': 'Missing required URL params: {names}.', - 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.', + 'auth-card.qr.step-3': 'Tap “Link a device” and scan this QR code.', + + // --- Global errors / notices --------------------------------------------- + 'error.network': 'No connection to the server. Check your internet and try again.', + 'error.auth': + 'Could not verify your account with the bridge. Reload the page; if that does not help, try again later.', + 'error.not-logged-in': + 'The WhatsApp session is no longer active. Go back and link the account again.', + 'error.openid-blocked': + 'The host did not grant the widget sign-in permission — the bot in config.json is missing the vojo.openid capability.', + 'error.rate-limited': 'WhatsApp asks to wait: too many code requests. Try again later.', + 'error.phone-invalid': + 'WhatsApp rejected this number. Enter it in international format and try again.', + 'error.multidevice': + 'WhatsApp multi-device is not enabled on your phone. Update the WhatsApp app and try again.', + 'error.client-outdated': + 'The bridge is outdated and WhatsApp refuses its connection. Tell the Vojo administrator.', + 'error.pair-failed': 'WhatsApp did not confirm the pairing. Start the sign-in over.', + 'error.login-timeout': 'The sign-in window expired. Start over.', + 'error.login-restart': 'The sign-in session was lost. Start over.', + 'error.too-many-logins': 'Linked-account limit reached. Sign out of the current one first.', + 'error.generic': 'Something went wrong: {reason}', + 'notice.login-success': 'WhatsApp is linked! Chats will appear in the list within a minute.', + 'notice.logged-out': 'WhatsApp session ended.', + + // --- Contacts ------------------------------------------------------------ + 'card.contacts.name': 'Contacts', + 'card.contacts.desc': 'Your WhatsApp address book: search by name or number', + 'contacts.back': 'Back', + 'contacts.search-placeholder': 'Name or +number…', + 'contacts.hint': + 'These are the contacts from your WhatsApp address book. Pick who to start a chat with in Vojo — the rest are not going anywhere.', + 'contacts.loading': 'Loading contacts…', + 'contacts.error': 'Could not load contacts.', + 'contacts.retry': 'Retry', + 'contacts.empty': 'Your WhatsApp address book is empty so far.', + 'contacts.empty-filtered': 'Nobody found with that name.', + 'contacts.start-chat': 'Start chat', + 'contacts.open-chat': 'Open chat', + 'contacts.creating': 'Creating chat…', + 'contacts.opening': 'Opening…', + 'contacts.probe-check': 'Check {handle} on WhatsApp', + 'contacts.probe-checking': 'Checking {handle}…', + 'contacts.probe-not-found': '{handle} was not found on WhatsApp.', + 'contacts.probe-found': 'Found them! You can start a chat.', + 'contacts.probe-self': 'That is your own number.', + 'contacts.refresh': 'Refresh list', + + // --- Account tab ----------------------------------------------------------- + 'account.state-bad': + 'The bridge reports a connection problem: {reason}. Try signing out and linking WhatsApp again.', + 'account.state-transient': + 'The bridge temporarily lost the connection to WhatsApp. Check that your phone is online — it usually recovers on its own.', + + // --- Boot / config --------------------------------------------------------- + 'boot.connecting': 'Connecting to the bridge…', + 'config.missing.title': 'Server-side setup required', + 'config.missing.body': + 'The widget talks to the bridge API, but its address is not configured (experience.provisioningUrl in config.json) or the vojo.openid permission is missing.', + 'error.retry': 'Retry', + + // --- Bootstrap failure ------------------------------------------------- + 'bootstrap.failed': 'The widget failed to start', + 'bootstrap.missing-params': 'Required URL parameters are missing: {names}.', + 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at the {route} route.', }; diff --git a/apps/widget-whatsapp/src/i18n/index.ts b/apps/widget-whatsapp/src/i18n/index.ts index 1af7a208..686c299a 100644 --- a/apps/widget-whatsapp/src/i18n/index.ts +++ b/apps/widget-whatsapp/src/i18n/index.ts @@ -1,7 +1,7 @@ -// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix -// match — any `en` variant). Bootstrap forwards `clientLanguage` from -// the host; main.tsx can also call `createT()` without args before -// bootstrap completes (falls back to navigator.language, then RU). +// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match — +// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx +// can also call `createT()` without args before bootstrap completes (falls back +// to navigator.language, then RU). import { RU, type StringKey } from './ru'; import { EN } from './en'; diff --git a/apps/widget-whatsapp/src/i18n/ru.ts b/apps/widget-whatsapp/src/i18n/ru.ts index 7239b2de..bbbdd996 100644 --- a/apps/widget-whatsapp/src/i18n/ru.ts +++ b/apps/widget-whatsapp/src/i18n/ru.ts @@ -4,77 +4,61 @@ // 2. add the same key + EN value in `en.ts`, // 3. consume via `t('key', { var: 'x' })` in components. // Interpolation uses `{name}` placeholders resolved against the second arg. -// -// The widget no longer renders a hero — that block lives in the host's -// BotShellHero. Status is surfaced inline inside the relevant section. export const RU = { - // --- Inline section status --------------------------------------------- - 'status.unknown': 'Проверка статуса…', + // --- Status pill --------------------------------------------------------- + 'status.checking': 'Проверка статуса…', 'status.disconnected': 'WhatsApp не привязан', - 'status.connected': 'WhatsApp привязан', - 'status.connected-as': 'WhatsApp привязан как {handle}', - 'status.logging-out': 'Завершение сеанса…', - // QR-вход: после успешного скана мост стирает QR и переходит к - // подтверждению линка. Это короткий промежуточный pill. - 'status.qr-verifying': 'Проверяем вход…', - // Pairing-code вход: после ввода кода в приложении ждём, пока WhatsApp - // подтвердит линк. По времени совпадает с qr-verifying — секунды. - 'status.pairing-verifying': 'Проверяем вход…', - // --- Section headers --------------------------------------------------- + 'status.connected-as': 'Привязан как {handle}', + + // --- Action cards ---------------------------------------------------------- + // User flow по сути такой же, как в Telegram: сабмит номера → код. Отличие: + // в TG код вводится в виджет, в WA — в само приложение WhatsApp. Имя кнопки + // одинаковое для consistency между виджетами. + 'card.login.name': 'Войти по номеру', + 'card.login.desc': 'Ввести номер и получить 8-символьный код для WhatsApp', 'card.login-qr.name': 'Войти по QR-коду', 'card.login-qr.desc': 'Отсканировать QR из мобильного приложения WhatsApp', - // WA-эквивалент TG-шного «Войти по номеру». User flow по сути такой - // же, как в Telegram: сабмит номера → бот выдаёт код → код вводится. - // Отличие: в TG код вводится в виджет, в WA — в само приложение - // WhatsApp. Имя кнопки одинаковое для consistency между виджетами. - 'card.login-pairing.name': 'Войти по номеру', - 'card.login-pairing.desc': 'Ввести номер и получить 8-символьный код для WhatsApp', - 'card.refresh.aria': 'Обновить статус', - 'card.refresh.label': 'Обновить статус', 'card.refresh.name': 'Обновить статус', 'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp', 'card.refresh.in-flight': 'Проверяю…', - // --- About panel ------------------------------------------------------- - // WhatsApp-only Meta-ToS risk disclosure is folded into the About - // modal as an amber callout at the top of the body. The AboutCard - // itself carries `command-card warn` (amber border + amber name) - // and a triangle warning glyph in the lead slot — instead of the - // info-circle TG / Discord use — so the «риски» half of the hybrid - // description («о работе и рисках») is visible at a glance before - // the user opens the modal. TG / Discord get the plain «вход, - // безопасность, исходный код» variant because they don't carry an - // account-loss risk in the same way (Telegram user ToS doesn't - // forbid third-party clients; Discord's restriction on self-bots - // lives in developer policies, not user ToS proper). The amber - // block keeps the unique WhatsApp framing without claiming anything - // about TG / Discord by comparison. + 'card.logout.name': 'Выйти из WhatsApp', + 'card.logout.desc': 'Завершить сеанс на этом аккаунте', + 'card.logout.confirm-prompt': 'Точно выйти?', + 'card.logout.confirm-yes': 'Выйти', + 'card.logout.confirm-no': 'Отмена', + + // --- About panel ----------------------------------------------------------- + // WhatsApp-only Meta-ToS risk disclosure is folded into the About modal as + // an amber callout at the top of the body. The About card itself carries + // `command-card warn` (amber border + amber name) and a triangle warning + // glyph in the lead slot — instead of the info-circle TG / Discord use — + // so the «риски» half of the hybrid description («о работе и рисках») is + // visible at a glance before the user opens the modal. TG / Discord get + // the plain «вход, безопасность, исходный код» variant because they don't + // carry an account-loss risk in the same way (Telegram user ToS doesn't + // forbid third-party clients; Discord's restriction on self-bots lives in + // developer policies, not user ToS proper). // // ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service - // section «Harm To WhatsApp Or Our Users» forbids «software or - // APIs that function substantially the same as our Services» and - // «accounts for our Services through unauthorized or automated - // means». + // section «Harm To WhatsApp Or Our Users» forbids «software or APIs that + // function substantially the same as our Services» and «accounts for our + // Services through unauthorized or automated means». + 'card.about.name': 'Как работает WhatsApp-бот', + 'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть', 'warning.title': 'Важно знать до подключения WhatsApp', 'warning.body-1': 'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.', - // Источник про запрет в ToS — даём юзеру возможность дойти до - // оригинала самому, не доверять нам на слово. Кликается потому что - // host-side iframe sandbox получил allow-popups (см. - // src/app/features/bots/BotWidgetEmbed.ts). + // Источник про запрет в ToS — даём юзеру возможность дойти до оригинала + // самому, не доверять нам на слово. Кликается потому что host-side iframe + // sandbox получил allow-popups (см. src/app/features/bots/BotWidgetEmbed.ts). 'warning.tos-label': 'Условия использования WhatsApp:', 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', - 'card.about.name': 'Как работает WhatsApp-бот', - // Hybrid copy: tells the user the modal carries BOTH the «как - // работает» explainer AND the Meta-ToS risk disclosure. «нажмите, - // чтобы прочесть» reinforces interactivity — the amber border + - // warning triangle help but the explicit verb seals it. - 'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть', 'about.title': 'О боте WhatsApp', 'about.body-1': 'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.', 'about.body-2': - 'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».', + 'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязка устройства», либо ввести 8-символьный код через «Настройки → Связанные устройства → Связать по номеру телефона».', 'about.body-3': 'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.', 'about.github-label': 'Исходный код моста открыт на GitHub:', @@ -83,8 +67,9 @@ export const RU = { 'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».', 'about.close': 'Закрыть', 'about.aria-close': 'Закрыть «О боте»', - // --- Phone form (pairing-code flow) ------------------------------------ - 'auth-card.phone.title': 'Вход по коду из приложения', + + // --- Phone form (pairing-code flow) ----------------------------------------- + 'auth-card.phone.title': 'Вход по номеру телефона', 'auth-card.phone.label': 'Номер телефона', 'auth-card.phone.placeholder': '+79991234567', // Подсказка, объясняющая что произойдёт после сабмита: мост создаст @@ -95,96 +80,104 @@ export const RU = { 'auth-card.phone.submit': 'Получить код', 'auth-card.phone.cooldown': 'Повтор через {seconds} сек', 'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.', - // --- Pairing-code form ------------------------------------------------- + + // --- Pairing-code panel -------------------------------------------------- 'auth-card.pairing-code.title': 'Введите этот код в WhatsApp', 'auth-card.pairing-code.hint': - 'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».', - 'auth-card.pairing-code.preparing': 'Готовим код…', + 'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Связать по номеру телефона».', 'auth-card.pairing-code.aria': 'Код для входа в WhatsApp. Введите его в приложении на телефоне.', 'auth-card.pairing-code.countdown': 'На ввод осталось {minutes}:{seconds}', 'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', 'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.', 'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».', 'auth-card.pairing-code.step-3': - 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».', + 'Нажмите «Привязка устройства», затем «Связать по номеру телефона».', 'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.', - // --- QR form ----------------------------------------------------------- + 'auth-card.pairing-code.copy': 'Скопировать код', + 'auth-card.pairing-code.copied': 'Скопировано', + + // --- Shared form chrome ------------------------------------------------ + 'auth-card.cancel': 'Отмена', + 'auth-card.waiting-hint': 'Мост ещё думает… ответ может идти до 30 секунд.', + + // --- QR panel ------------------------------------------------------------ 'auth-card.qr.title': 'Вход по QR-коду', 'auth-card.qr.hint': 'Откройте WhatsApp на телефоне и отсканируйте этот QR-код.', 'auth-card.qr.preparing': 'Готовим QR-код…', 'auth-card.qr.aria': 'QR-код для входа в WhatsApp. Отсканируйте его телефоном.', // Обратный отсчёт до серверного таймаута. Whatsmeow ротирует QR по - // расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в - // панели всегда свежий (мост шлёт m.replace edits на каждой ротации), - // отсчёт показывает оставшееся окно ВСЕГО входа. + // расписанию 60 с + 5 × 20 с = 2 мин 40 с активного окна. Сам QR в панели + // всегда свежий (long-poll приносит новый на каждой ротации), отсчёт + // показывает оставшееся окно ВСЕГО входа. 'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}', 'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', 'auth-card.qr.step-1': 'Откройте WhatsApp на телефоне.', 'auth-card.qr.step-2': 'Перейдите в «Настройки → Связанные устройства».', - 'auth-card.qr.step-3': 'Нажмите «Привязать устройство» и отсканируйте QR-код.', - // --- Shared form chrome ------------------------------------------------ - 'auth-card.cancel': 'Отмена', - 'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.', - // --- Inline errors ----------------------------------------------------- - // login_failed reasons — мы сохраняем верхатимный текст ошибки от - // upstream. Это даёт юзеру максимально точную диагностику без перевода, - // которое может разъехаться с реальной причиной. Шаблон обёрнут. - 'auth-error.login-failed': 'Не удалось войти: {reason}', - 'auth-error.invalid-value': 'Значение не принято: {reason}', - 'auth-error.submit-failed': 'WhatsApp не принял ввод: {reason}', - 'auth-error.start-failed': 'Не удалось начать вход: {reason}', - 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', - 'auth-error.login-in-progress': - 'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.', - 'auth-error.max-logins': - 'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.', - 'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.', - // External-logout варианты — три причины, у каждой своя UX-формулировка. - // «another_device» — другой связанный девайс отвязал нас (например, юзер - // отвязал bridge с другого ноутбука). «phone_logged_out» — юзер вышел - // из WhatsApp на самом телефоне, что ломает все связанные устройства. - // «unknown» — fallback, в т.ч. для startup-нотисов «You're not logged - // into WhatsApp». - 'auth-error.external-logout.another-device': - 'WhatsApp отвязал это устройство с другого устройства. Войдите снова.', - 'auth-error.external-logout.phone-logged-out': - 'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.', - 'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.', - // --- Logout ------------------------------------------------------------ - 'card.logout.name': 'Выйти из WhatsApp', - 'card.logout.desc': 'Завершить сеанс на этом аккаунте', - 'card.logout.confirm-prompt': 'Точно выйти?', - 'card.logout.confirm-yes': 'Выйти', - 'card.logout.confirm-no': 'Отмена', - 'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.', - // --- Diagnostics in transcript ---------------------------------------- - 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', - 'diag.ready': 'Готов отправлять команды.', - 'diag.checking-status': 'Проверяю статус подключения…', - 'diag.send-failed': 'ошибка отправки: {message}', - 'diag.history-marker': '─── история ───', - 'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.', - // QR-сообщения никогда не выводятся целиком в transcript — body содержит - // raw whatsmeow handshake (включая adv-secret, который IS the login - // token). Сохранять его в DOM-логе виджета означало бы пережить мост- - // редакцию. В логе только нейтральные диагностические строки. - 'diag.qr-issued': 'QR-код обновлён.', - 'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.', - // Pairing-код — не такой же чувствительный как QR adv-secret (это - // 8-символьный one-time pairing token, действителен ~3 минуты), но - // всё равно по аналогии с QR не дублируем его в transcript — UI и так - // показывает код большим моноширинным текстом. В логе только нейтральная - // диагностика, чтобы trail был последовательный. - 'diag.pairing-code-issued': 'Код для входа выдан.', - // Connection warnings от connector handlewhatsapp.go — они не меняют - // state виджета, просто пишутся в transcript verbatim, чтобы юзер - // понимал, что мост борется с подключением. - 'diag.connection-warning': '{text}', - // External-logout transcript echo — короткая строка под красным - // баннером. - 'diag.external-logout': 'WhatsApp разорвал сессию — нужен повторный вход.', + 'auth-card.qr.step-3': 'Нажмите «Привязка устройства» и отсканируйте QR-код.', + + // --- Global errors / notices --------------------------------------------- + 'error.network': 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.', + 'error.auth': + 'Не удалось подтвердить ваш аккаунт у моста. Обновите страницу; если не помогает — попробуйте позже.', + 'error.not-logged-in': + 'Сессия WhatsApp больше не активна. Вернитесь назад и привяжите аккаунт заново.', + 'error.openid-blocked': + 'Хост не выдал виджету разрешение на вход — в config.json у бота нет capability vojo.openid.', + 'error.rate-limited': 'WhatsApp просит подождать: слишком много запросов кода. Попробуйте позже.', + 'error.phone-invalid': + 'WhatsApp не принял этот номер. Укажите его в международном формате и попробуйте снова.', + 'error.multidevice': + 'На телефоне не включён мультиустройственный режим WhatsApp. Обновите приложение WhatsApp и попробуйте снова.', + 'error.client-outdated': + 'Мост устарел, и WhatsApp отказывает ему в подключении. Сообщите администратору Vojo.', + 'error.pair-failed': 'WhatsApp не подтвердил привязку. Начните вход заново.', + 'error.login-timeout': 'Время входа истекло. Начните вход заново.', + 'error.login-restart': 'Сессия входа потерялась. Начните вход заново.', + 'error.too-many-logins': 'Достигнут лимит привязанных аккаунтов. Сначала выйдите из текущего.', + 'error.generic': 'Что-то пошло не так: {reason}', + 'notice.login-success': 'WhatsApp привязан! Чаты появятся в списке в течение минуты.', + 'notice.logged-out': 'Сеанс WhatsApp завершён.', + + // --- Contacts ------------------------------------------------------------ + 'card.contacts.name': 'Контакты', + 'card.contacts.desc': 'Записная книжка WhatsApp: поиск по имени или номеру', + 'contacts.back': 'Назад', + 'contacts.search-placeholder': 'Имя или +номер…', + 'contacts.hint': + 'Это контакты вашей записной книжки WhatsApp. Выберите, с кем начать чат в Vojo, — остальные никуда не денутся.', + 'contacts.loading': 'Загружаем контакты…', + 'contacts.error': 'Не удалось загрузить контакты.', + 'contacts.retry': 'Повторить', + 'contacts.empty': 'В записной книжке WhatsApp пока пусто.', + 'contacts.empty-filtered': 'Никого не нашли с таким именем.', + 'contacts.start-chat': 'Начать чат', + 'contacts.open-chat': 'Открыть чат', + 'contacts.creating': 'Создаём чат…', + 'contacts.opening': 'Открываем…', + 'contacts.probe-check': 'Проверить {handle} в WhatsApp', + 'contacts.probe-checking': 'Проверяем {handle}…', + 'contacts.probe-not-found': '{handle} не найден в WhatsApp.', + 'contacts.probe-found': 'Есть такой! Можно написать.', + 'contacts.probe-self': 'Это ваш собственный номер.', + 'contacts.refresh': 'Обновить список', + + // --- Account tab ----------------------------------------------------------- + 'account.state-bad': + 'Мост сообщает о проблеме с подключением: {reason}. Попробуйте выйти и привязать WhatsApp заново.', + // TRANSIENT_DISCONNECT — телефон давно не в сети / keepalive потерян + // (connector phoneping.go). Обычно чинится само, перепривязка не нужна. + 'account.state-transient': + 'Мост временно потерял связь с WhatsApp. Проверьте, что телефон в сети, — обычно подключение восстанавливается само.', + + // --- Boot / config --------------------------------------------------------- + 'boot.connecting': 'Подключение к мосту…', + 'config.missing.title': 'Нужна настройка на сервере', + 'config.missing.body': + 'Виджет работает через API моста, но его адрес не задан в конфигурации (experience.provisioningUrl в config.json) или не выдано разрешение vojo.openid.', + 'error.retry': 'Повторить', + // --- Bootstrap failure ------------------------------------------------- - 'bootstrap.failed': 'Widget не запустился', + 'bootstrap.failed': 'Виджет не запустился', 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.', } as const; diff --git a/apps/widget-whatsapp/src/login.tsx b/apps/widget-whatsapp/src/login.tsx new file mode 100644 index 00000000..1e47213c --- /dev/null +++ b/apps/widget-whatsapp/src/login.tsx @@ -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 => + 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({ kind: 'idle' }); + const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(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(null); + const lastPhoneRef = useRef(''); + + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + + useEffect( + () => () => { + generation.current += 1; + waitAbort.current?.abort(); + }, + [] + ); + + return useMemo(() => { + // 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(() => + flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined + ); + const inputRef = useRef(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 ( +
+
{t('auth-card.phone.title')}
+ +
+
+ {flagEmoji ? ( + + ) : null} + { + const raw = (e.currentTarget as HTMLInputElement).value; + const next = formatPhoneInput(raw); + setValue(next.formatted); + setCountry(next.country); + }} + disabled={busy} + /> +
+ + +
+
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} + {error ?
{error}
: null} + {busy && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// --- 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 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( + + ); + } + } + return ( + + {rects} + + ); +}; + +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 ( +
+
{t('auth-card.qr.title')}
+
{t('auth-card.qr.hint')}
+
+ {matrix ? ( + // The aria-label describes the PURPOSE of the QR, not its contents — + // the payload itself is the login secret. + + ) : ( +
+ + {t('auth-card.qr.preparing')} +
+ )} +
+ {!expired ? ( +
+ {t('auth-card.qr.countdown', { + minutes: String(Math.floor(remainingSeconds / 60)), + seconds: String(remainingSeconds % 60).padStart(2, '0'), + })} +
+ ) : ( +
{t('auth-card.qr.expired')}
+ )} +
    +
  1. {t('auth-card.qr.step-1')}
  2. +
  3. {t('auth-card.qr.step-2')}
  4. +
  5. {t('auth-card.qr.step-3')}
  6. +
+
+ +
+
+ ); +}; + +// 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 => { + 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(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 ( +
+
{t('auth-card.pairing-code.title')}
+
{t('auth-card.pairing-code.hint')}
+
+ {code} +
+ {!expired ? ( +
+ {t('auth-card.pairing-code.countdown', { + minutes: String(Math.floor(remainingSeconds / 60)), + seconds: String(remainingSeconds % 60).padStart(2, '0'), + })} +
+ ) : ( +
{t('auth-card.pairing-code.expired')}
+ )} +
    +
  1. {t('auth-card.pairing-code.step-1')}
  2. +
  3. {t('auth-card.pairing-code.step-2')}
  4. +
  5. {t('auth-card.pairing-code.step-3')}
  6. +
  7. {t('auth-card.pairing-code.step-4')}
  8. +
+
+ + +
+
+ ); +}; diff --git a/apps/widget-whatsapp/src/main.tsx b/apps/widget-whatsapp/src/main.tsx index 83034990..35dbac1d 100644 --- a/apps/widget-whatsapp/src/main.tsx +++ b/apps/widget-whatsapp/src/main.tsx @@ -2,15 +2,29 @@ import { render } from 'preact'; import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; -import { WidgetApi, buildCapabilities } from './widget-api'; +import { WidgetApi } from './widget-api'; import './styles.css'; -// Input-mode detector — see apps/widget-telegram/src/main.tsx for the -// full rationale. Default to 'mouse'; the capture-phase pointerdown -// listener flips to 'touch' on the first non-mouse pointerType. -// matchMedia guessing was dropped — every variant -// (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`) -// is mis-reported on at least one shipping device. +// Input-mode detector for hover styling. CSS gates `:hover` and +// `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's +// Android Chromium WebView synthesises `:hover` on the focused element +// after a tap and never clears it until the next interaction elsewhere — +// without the gate, every tap leaves a sticky hover state on the tapped +// card («card greys out after tap and only un-greys when you tap a +// different button»). +// +// Truth comes from `pointerdown.pointerType`. The capture-phase listener +// runs in the same task as any post-tap `:hover` synthesis, so a touch +// tap on Android lands in 'touch' mode in the same render frame as the +// synthesised hover would paint. +// +// Initial mode is plain 'mouse' — matchMedia-based guessing was tried and +// dropped: every interaction-media query is mis-reported on at least one +// shipping device (see git history for the survey). Defaulting to 'mouse' +// is strictly no worse on any device: a desktop user gets hover from frame +// zero, and a touch user cannot trigger `:hover` before tapping — by the +// time the first tap fires, our listener has already moved the attribute +// to 'touch'. const setInputMode = (mode: 'touch' | 'mouse'): void => { document.documentElement.dataset.input = mode; }; @@ -52,19 +66,12 @@ if (!result.ok) { // through the wrong palette. document.documentElement.dataset.theme = result.bootstrap.theme; - // Instantiate the WidgetApi BEFORE React render. The constructor attaches - // the `window.addEventListener('message', ...)` listener synchronously, - // so by the time the host's ClientWidgetApi fires its capabilities - // request on iframe `load` we're already listening. - // - // The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which - // runs AFTER React's first commit. On a fresh mount the bundle parse + - // initial render took long enough for the host's request to arrive - // after the listener was attached, so it worked by accident. On the - // *second* mount (after «Show chat» → «Show widget») the bundle is - // browser-cached and parses near-instantly; the host's request raced - // ahead of useEffect, the listener missed it, and capability handshake - // hung forever — only the «Соединение с Vojo…» diag line ever showed. - const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId)); + // Instantiate the WidgetApi BEFORE the first render. The constructor + // attaches the `window.addEventListener('message', ...)` listener + // synchronously, so by the time the host's ClientWidgetApi fires its + // capabilities request on iframe `load` we're already listening. On a + // cached-bundle remount the request can race ahead of any useEffect — + // construction at module-load closes that window. + const api = new WidgetApi(result.bootstrap); render(, root); } diff --git a/apps/widget-whatsapp/src/provisioning.ts b/apps/widget-whatsapp/src/provisioning.ts new file mode 100644 index 00000000..69e38985 --- /dev/null +++ b/apps/widget-whatsapp/src/provisioning.ts @@ -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:` — 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:+`; empty for lid-/bot- contacts + * (connector userinfo.go contactToUserInfo). */ + identifiers?: string[]; + /** Ghost MXID (`@whatsapp_: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 | null = null; + + public constructor( + private readonly baseUrl: string, + private readonly userId: string, + private readonly fetchCredentials: () => Promise + ) {} + + // -- auth plumbing -- + + private getToken(force = false): Promise { + 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(method: string, path: string, opts: RequestOptions = {}): Promise { + const attempt = async (forceToken: boolean): Promise => { + 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 { + return this.request('GET', '/v3/whoami'); + } + + public async listContacts(): Promise { + 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 { + try { + return await this.request( + '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 { + return this.request('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 { + return this.request('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 + ): Promise { + return this.request( + '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 { + return this.request( + '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 { + return this.request('POST', `/v3/login/cancel/${encodeURIComponent(loginId)}`); + } + + public logout(loginId: string): Promise { + return this.request('POST', `/v3/logout/${encodeURIComponent(loginId)}`); + } +} + +// --- Identifier helpers (shared by contacts search + probe) ----------------- + +/** Loose phone shape — digits with optional separators. Normalised to + * `+` 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 }; +}; diff --git a/apps/widget-whatsapp/src/state.ts b/apps/widget-whatsapp/src/state.ts deleted file mode 100644 index d4c99c2e..00000000 --- a/apps/widget-whatsapp/src/state.ts +++ /dev/null @@ -1,1026 +0,0 @@ -// Login state machine — consumes LoginEvent (one per inbound bridge bot -// reply) and emits a typed UI state. The widget renders forms / QR panel / -// pairing-code panel / status pill from this state, never from raw reply -// strings. -// -// WhatsApp vs Telegram differences (both bridgev2): -// - TWO login flows: `qr` and `phone` (pairing code). The widget always -// sends the full `login qr` / `login phone` command — never bare -// `login` (which would trigger a flow_required reply). -// - NO 2FA cloud password — multidevice handshake is single-factor. -// The reducer has no `awaiting_password` / `twofa_required` arms. -// - QR data is a raw whatsmeow handshake (not a URL) — handled by -// parser, reducer just carries the opaque string. -// - QR rotation: 60 s for first QR, 5 more × 20 s. Total active window -// 2 min 40 s (vs Telegram's 10 min). Hydrate freshness window -// correspondingly tightened to 3 min. -// - Pairing code: NEW intermediate states (`awaiting_pairing_code`, -// `pairing_code_shown`). The bridge replies in two notices — -// instructions then code — so the reducer flips through both. -// - Login success format `Successfully logged in as +`: handle -// IS the phone number, no separate numericId. -// - Async session events: `external_logout` flips disconnected with a -// warn flag; `connection_warning` is transcript-only (state untouched). - -import type { LoginEvent, ListedLogin, ExternalLogoutReason } from './bridge-protocol/types'; - -export type LoginErrorFlag = - // login_failed reasons (connector-side errors all funnel through here). - // We don't sub-classify by reason text — upstream wording is structured - // enough that the user can read the reason verbatim. - | { kind: 'login_failed'; reason?: string } - | { kind: 'invalid_value'; reason?: string } - | { kind: 'submit_failed'; reason?: string } - | { kind: 'prepare_failed'; reason?: string } - | { kind: 'start_failed'; reason?: string } - | { kind: 'login_in_progress' } - | { kind: 'max_logins'; limit?: number } - | { kind: 'unknown_command' } - | { kind: 'external_logout'; reason: ExternalLogoutReason }; - -// A live form is open and waiting for user input. WhatsApp ships THREE: -// - phone-number form (pairing-code flow only) -// - QR-scan panel (qr flow) -// - pairing-code shown (phone flow, after the bridge generated a code) -// Plus an `awaiting_pairing_code` interstitial — we know the user submitted -// a phone, the bridge accepted it, and we're waiting for the code to land. -export type PendingFormState = - | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } - | { kind: 'awaiting_pairing_code'; lastError?: LoginErrorFlag } - | { - kind: 'pairing_code_shown'; - code: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - } - | { - kind: 'awaiting_qr_scan'; - qrData: string; - qrEventId: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - }; - -export type LoginState = - // Pre-handshake / pre-list-logins. Status pill: --faint. - | { kind: 'unknown' } - // list-logins came back empty, OR logout completed, OR external_logout - // landed. Status pill: --rose. lastError carries the most recent - // structured error (including external_logout reason). - | { kind: 'disconnected'; lastError?: LoginErrorFlag } - | PendingFormState - // QR was redacted (i.e. the bridge accepted a scan), but we don't yet - // know whether the phone-side handshake completed. Held as a spinner - // until the next bridge signal arrives. NOT terminal — `login_success` - // flips to `connected`. - | { kind: 'qr_verifying' } - // Pairing-code accepted by phone, waiting for login_success. WhatsApp - // doesn't redact the code message (no analog to QR redaction), so this - // state is reached optimistically by the App when the code-shown panel - // sees its own success wait window run out OR when the user explicitly - // confirms. M-discord uses `qr_verifying` for a similar gap. Reserved - // here in case future versions of mautrix-whatsapp redact the code on - // success — the live reducer would still need somewhere to land. - | { kind: 'pairing_verifying' } - // logout in flight — waiting for `Logged out`. Status pill: --amber. - | { kind: 'logging_out'; loginId: string } - // Live session. login carries the phone-number handle parsed from - // `Successfully logged in as +`, plus the loginId we need for - // `!wa logout `. - | { - kind: 'connected'; - handle: string; - loginId?: string; - }; - -// States that the hydrate path can restore after a reload. Equals -// PendingFormState (live forms waiting for input) plus interstitials -// (`qr_verifying`, `pairing_verifying`) for the brief gap between -// scan-accept and the next bridge signal. Other transient states -// (logging_out) deliberately don't survive — those are tied to live -// in-flight commands and would feel stuck on reload; the hydrate path -// falls through to live `list-logins`. -export type HydrateRestoredState = - | PendingFormState - | { kind: 'qr_verifying' } - | { kind: 'pairing_verifying' }; - -// Outbound user actions the App dispatches. -export type LoginAction = - | { kind: 'event'; event: LoginEvent } - | { kind: 'start_qr_login' } // user clicked «Войти по QR-коду» - | { kind: 'start_phone_login' } // user clicked «Войти по коду из приложения» - | { kind: 'submit_phone' } // user clicked submit on phone form - | { kind: 'request_logout'; loginId: string } // user clicked «Выйти» - | { kind: 'cancel_pending' } // user clicked «Отмена» - | { kind: 'hydrate'; state: HydrateRestoredState }; - -export const initialLoginState: LoginState = { kind: 'unknown' }; - -const pickConnected = (logins: ListedLogin[]): LoginState => { - if (logins.length === 0) return { kind: 'disconnected' }; - // M-WA ships single-account UI (max_logins=1 in the operator's bridge - // config). If a future deployment runs with multiple logins, we still - // surface the first one — multi-account UI is a follow-up phase. - const [first] = logins; - return { - kind: 'connected', - handle: first.name, // RemoteName = "+" - loginId: first.id, - }; -}; - -// Whether step-scoped errors (invalid_value, submit_failed) should land on -// a form. Form-scoped errors are dropped when no form is open. Shared by -// the live reducer and the hydrate path. -const isFormState = (s: LoginState): s is PendingFormState => - s.kind === 'awaiting_phone' || - s.kind === 'awaiting_pairing_code' || - s.kind === 'pairing_code_shown' || - s.kind === 'awaiting_qr_scan'; - -export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { - if (action.kind === 'hydrate') { - // hydrate is a one-shot mount-time seed. It races against live events - // that may arrive between `on('ready')` firing and our async - // readTimeline resolving. If a live event has already moved us off - // `unknown`, the live truth wins; the cached timeline snapshot is by - // definition older. - if (state.kind !== 'unknown') return state; - return action.state; - } - if (action.kind === 'start_qr_login') { - // Optimistic placeholder QR-scan state. The actual qr_displayed event - // overwrites qrData / qrEventId / firstShownAt. If the - // `!wa login qr` send fails, the App rolls back to disconnected. - // - // `firstShownAt: 0` here (not Date.now()) so the QR-window countdown - // starts when the bridge actually ships the FIRST QR — not when the - // user clicked. Bridge takes 1-3 s to connect to whatsmeow + emit - // the first code; using the click time eats that off the user's - // visible 3-min window. QrPanel reads `firstShownAt > 0 ? ... : 0` - // and renders the countdown only once a real QR has landed. - return { - kind: 'awaiting_qr_scan', - qrData: '', - qrEventId: '', - firstShownAt: 0, - }; - } - if (action.kind === 'start_phone_login') { - return { kind: 'awaiting_phone' }; - } - if (action.kind === 'submit_phone') { - // Stay on the phone form until the bot confirms with the pairing-code - // instructions. Optimistic transition to awaiting_pairing_code would - // mis-surface a phone-side error (e.g. `Phone number too short`) - // on the wrong panel. - if (state.kind === 'awaiting_phone') { - return { kind: 'awaiting_phone', lastError: undefined }; - } - return state; - } - if (action.kind === 'request_logout') { - return { kind: 'logging_out', loginId: action.loginId }; - } - if (action.kind === 'cancel_pending') { - // Optimistic: drop straight back to disconnected. The bot's reply - // will be `Login cancelled.` (cancel_ok) or `No ongoing command.` - // (cancel_no_op) — either way the user has signalled they want out. - return { kind: 'disconnected' }; - } - - const event = action.event; - switch (event.kind) { - case 'logins_listed': - // list-logins is the source of truth — accept from any state. - return pickConnected(event.logins); - - case 'not_logged_in': - // Late-arriving `You're not logged in` from a list-logins fired - // before the user started a fresh login flow would otherwise wipe - // an active form. Accept only from states where flipping to - // disconnected is correct. - if ( - state.kind === 'unknown' || - state.kind === 'disconnected' || - state.kind === 'logging_out' || - state.kind === 'qr_verifying' || - state.kind === 'pairing_verifying' - ) { - return { kind: 'disconnected' }; - } - return state; - - case 'awaiting_phone': - // Bot's "Please enter your Phone number". Only meaningful when we - // initiated phone-login (state already awaiting_phone). From any - // other state — including a late-arriving prompt after a cancel - // — drop it on the floor. - return state; - - case 'pairing_code_instructions': - // First of two notices after a phone submit. Plausible only when - // we're on the phone form OR already in the pairing-code - // interstitial (re-prompt scenario, defensive). Late arrival from - // a cancelled flow (user cancel + bridge already submitted phone) - // is dropped — the reducer doesn't resurrect dead flows. - if (state.kind === 'awaiting_phone') { - return { kind: 'awaiting_pairing_code' }; - } - if (state.kind === 'awaiting_pairing_code') { - return state; - } - return state; - - case 'pairing_code_displayed': { - // Second of the two notices — the actual XXXX-XXXX. Plausible - // from awaiting_pairing_code (the normal post-submit flow) OR - // from awaiting_phone (defensive — if the instructions notice - // was missed/dropped on the wire, the code itself is the - // operative signal). Also accept from pairing_code_shown to - // tolerate the bridge re-emitting the code (rare). - const accepts = - state.kind === 'awaiting_phone' || - state.kind === 'awaiting_pairing_code' || - state.kind === 'pairing_code_shown'; - if (!accepts) return state; - return { - kind: 'pairing_code_shown', - code: event.code, - firstShownAt: - state.kind === 'pairing_code_shown' && state.firstShownAt > 0 - ? state.firstShownAt - : Date.now(), - }; - } - - case 'login_success': - // Always honour — even if state somehow drifted, the bridge says - // we're in. handle is "+"; loginId is unknown until - // the post-success list-logins fires (App.tsx). - return { - kind: 'connected', - handle: event.handle, - }; - - case 'logout_ok': - // Late `Logged out` from a previous session can arrive while the - // user is mid-new-flow. Only honour from logging_out. - if (state.kind !== 'logging_out') return state; - return { kind: 'disconnected' }; - - case 'cancel_ok': - case 'cancel_no_op': - // The App's `cancel_pending` action ALWAYS optimistically lands us - // in `disconnected` before the bot's confirmation arrives. So a - // legitimate cancel-reply naturally finds state === 'disconnected' - // — accepting it then is a safe idempotent no-op. - // - // From ANY other state (awaiting_*, connected, logging_out, - // unknown), the cancel reply is stale: the user has either started - // a new flow (state already moved on) or never cancelled in this - // widget session at all. Letting it through would clobber an - // active flow. - if (state.kind !== 'disconnected') return state; - return { kind: 'disconnected' }; - - case 'login_in_progress': - if (isFormState(state)) { - return { ...state, lastError: { kind: 'login_in_progress' } }; - } - return state; - - case 'max_logins': - // Should not fire for max_logins=1 operators when our UI hides - // login while connected. If it does fire, the user is in a race; - // surface on disconnected so they can logout first. - return { kind: 'disconnected', lastError: { kind: 'max_logins', limit: event.limit } }; - - case 'login_not_found': - // Logout target id was wrong. Treat as disconnected — bridge clearly - // doesn't know that login id any more. - return { kind: 'disconnected' }; - - case 'invalid_value': - // Bridge rejected our submitted phone (e.g. malformed). Keep the - // form open with an error; if no form is open, ignore. - if (!isFormState(state)) return state; - return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } }; - - case 'submit_failed': - // WhatsApp-side error (Phone number too short, rate limited, etc.) - // leaked through bridgev2's commands layer. Hold the current form - // open so the user can retry; surface the verbatim Go error tail. - if (!isFormState(state)) return state; - return { ...state, lastError: { kind: 'submit_failed', reason: event.reason } }; - - case 'prepare_failed': - return { - kind: 'disconnected', - lastError: { kind: 'prepare_failed', reason: event.reason }, - }; - - case 'start_failed': - return { - kind: 'disconnected', - lastError: { kind: 'start_failed', reason: event.reason }, - }; - - case 'login_failed': - // bridgev2/commands/login.go sends `Login failed: ` after the - // display-and-wait branch's `login.Wait()` returns. For WhatsApp - // every connector RespError funnels through here. - // - // `context canceled` is an echo of OUR cancel — always a no-op. - // Anything else is a real failure (most commonly `Entering code or - // scanning QR timed out. Please try again.` after the 2 min 40 s - // window expires) — route to disconnected with the warning. We - // gate on form/QR/pairing states so a stale `login_failed` from a - // previous flow can't clobber a fresh one. - if (event.reason === 'context canceled') return state; - if (state.kind === 'disconnected') return state; - if ( - state.kind === 'connected' || - state.kind === 'logging_out' || - state.kind === 'unknown' - ) { - return state; - } - return { - kind: 'disconnected', - lastError: { kind: 'login_failed', reason: event.reason }, - }; - - case 'flow_required': - case 'flow_invalid': - // We always send `login qr` / `login phone` so this shouldn't - // happen. Visible if /config.json's commandPrefix drifted from - // the bridge's actual command_prefix or if a chat-fallback typist - // sent bare `!wa login`. Surface on disconnected — but only if - // we're not already connected. From `connected` the live session - // is intact and a chat-fallback typist sending bare `login` - // shouldn't clobber it (functional review #15). - if (state.kind === 'connected') return state; - return { - kind: 'disconnected', - lastError: { kind: 'start_failed', reason: 'flow' }, - }; - - case 'unknown_command': - // Shouldn't happen — we only send commands the bridge knows. If it - // does, the operator-config is mismatched. - return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; - - case 'qr_displayed': { - // Same anchor logic as the Telegram widget: `qrEventId` tracks the - // ORIGINAL bridge event. bridgev2 emits the QR as a single - // `m.image`, then on each rotation (per whatsmeow `qrIntervals`: - // 60 s + 5 × 20 s) edits the SAME event with - // `m.relates_to.rel_type=m.replace` + `event_id=`. - // - // Defence-in-depth: an inbound qr_displayed MUST carry a non-empty - // event id (otherwise an adversarial event could land in the - // placeholder slot and never be dislodged). The host driver - // sanitizer rejects empty event_id; this is redundant. - if (event.eventId.length === 0) return state; - - // Initial QR for this flow — accept from: - // * `unknown` — cold-start before list-logins resolves; - // * placeholder `awaiting_qr_scan{qrEventId=''}` set - // optimistically by `start_qr_login`; - // * `disconnected` — handles bridgev2's startup race. If the - // user clicks Cancel while bridge is still connecting to - // whatsmeow, the cancel arrives BEFORE CommandState is - // registered, replying cancel_no_op, and the bridge emits - // the QR anyway. We accept ONLY a fresh non-edit QR from - // `disconnected` — a `replacesEventId` here means a stale - // rotation from a flow we already cancelled (race functional - // review #5: edit with replaces=$qrA arrives after Cancel, - // we'd otherwise adopt the EDIT's event_id as a new anchor - // and the subsequent redaction targeting $qrA would be - // ignored). Drop edits in that situation. - if (event.replacesEventId && state.kind === 'disconnected') return state; - - if ( - state.kind === 'unknown' || - state.kind === 'disconnected' || - (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') - ) { - return { - kind: 'awaiting_qr_scan', - qrData: event.qrData, - qrEventId: event.eventId, - firstShownAt: - state.kind === 'awaiting_qr_scan' && state.firstShownAt - ? state.firstShownAt - : Date.now(), - }; - } - - if (state.kind !== 'awaiting_qr_scan') return state; - - // Rotation edit pointing at our anchor — repaint qrData, keep id. - if (event.replacesEventId === state.qrEventId) { - return { ...state, qrData: event.qrData }; - } - - // Fresh non-edit qr_displayed while we're already tracking one — - // could be a bridge restart of QR-login internally (rare; e.g. - // the bridge dropped the original event due to AS retry path). - // Adopt as new anchor BUT preserve the existing firstShownAt so - // the user-facing countdown doesn't reset (functional review #1: - // some edit-encoder paths can drop `m.relates_to`, which would - // otherwise pin firstShownAt to Date.now() every 20 s and the - // panel would never expire visibly). - if (!event.replacesEventId) { - return { - kind: 'awaiting_qr_scan', - qrData: event.qrData, - qrEventId: event.eventId, - firstShownAt: state.firstShownAt > 0 ? state.firstShownAt : Date.now(), - }; - } - - // Edit pointing at something we don't track — ignore. - return state; - } - - case 'qr_redacted': { - // Bridge cleaned up the QR after a successful scan. Held as - // `qr_verifying` until the next signal lands. - if (state.kind !== 'awaiting_qr_scan') return state; - if (state.qrEventId !== event.redactsEventId) return state; - return { kind: 'qr_verifying' }; - } - - case 'external_logout': - // WhatsApp lost its session externally (phone unlinked, another - // device kicked us, or the bridge lost auth on startup). Hard - // route to disconnected with the structured reason — the App - // surfaces a louder warn banner than ordinary form-side errors. - // Honour from any state because the bridge is authoritative - // about its own session loss. - return { - kind: 'disconnected', - lastError: { kind: 'external_logout', reason: event.reason }, - }; - - case 'connection_warning': - // Soft warning — surface in transcript only (App-level append), - // state untouched. The bridge is still operational. - return state; - - case 'unknown': - return state; - - default: { - // Exhaustiveness check — TS flags this if a new LoginEvent kind - // is added without a case here. - const exhaustive: never = event; - return exhaustive; - } - } -}; - -// --- Hydrate-from-timeline ----------------------------------------------- -// -// Same shape as the Telegram widget: walks bot replies in chronological -// order, permissively transitions state (no out-of-thin-air rejection -// because we trust durable timeline writes from a known sender). -// -// Hard scope: hydrate returns one of awaiting_phone / -// awaiting_pairing_code / pairing_code_shown / awaiting_qr_scan with -// optional lastError, OR qr_verifying / pairing_verifying interstitial, -// OR null. Terminal-ish events (login_success, logout_ok, cancel_*, -// not_logged_in, max_logins, login_not_found, prepare/start_failed, -// flow_*, unknown_command, external_logout) collapse the chain to null -// so App.tsx fires `list-logins` for authoritative reconciliation. - -// 3 minutes — covers the 2 min 40 s active QR window from whatsmeow's -// qrIntervals (60 s + 5 × 20 s) plus a small safety margin. Pairing-code -// server-side validity at WhatsApp's gateway is similar (~3 min); we -// share the same window. A reload past this point falls through to -// live list-logins. -const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000; - -export type HydrateInput = { - ev: LoginEvent; - // origin_server_ts of the underlying bridge event. Used for the - // freshness check on the LAST significant pending prompt only. - ts: number; -}; - -type HydrateAccumulator = { - state: LoginState; - // Timestamp of the most recent event that contributed to a non-unknown, - // non-terminal pending state. Drives the freshness gate. - pendingTs: number | null; - // Once a terminal event lands, we stop honouring later pending prompts - // in the same scan — terminal collapses the chain and any subsequent - // pending prompt is a fresh flow that the live `list-logins` reconciles. - terminated: boolean; -}; - -const stepHydrate = ( - prevAcc: HydrateAccumulator, - input: HydrateInput -): HydrateAccumulator => { - const { ev, ts } = input; - - // After a terminal event we normally stop tracking. Re-entry exception - // for `awaiting_phone` (re-issued `!wa login phone`) and FRESH - // `qr_displayed` (re-issued `!wa login qr`) — the user cancelled or - // finished and is now logging in again; the chain should resume - // tracking from the new start. Without this re-entry, sequences like - // [pairing_code_shown, cancel_ok, qr_displayed] - // would return null and regress an active flow. - // - // ROTATION-EDIT GUARD: a `qr_displayed` carrying `replacesEventId` - // is by definition an edit of an EARLIER QR — never a fresh flow's - // first QR. Mirrors the live reducer's guard against late rotations - // landing after Cancel: without this, a stale edit arriving 30 s - // post-cancel would resurrect a phantom QR panel that survives a - // page reload (until the freshness window expires). - const isFreshQrEntry = ev.kind === 'qr_displayed' && !ev.replacesEventId; - if ( - prevAcc.terminated && - ev.kind !== 'awaiting_phone' && - !isFreshQrEntry - ) { - return prevAcc; - } - // Restart-on-re-entry: clear the terminated bit AND any prior tracked - // state so the new flow's first event becomes the new anchor without - // inheriting the old QR's eventId. - const acc: HydrateAccumulator = prevAcc.terminated - ? { state: { kind: 'unknown' }, pendingTs: null, terminated: false } - : prevAcc; - - switch (ev.kind) { - case 'awaiting_phone': - return { state: { kind: 'awaiting_phone' }, pendingTs: ts, terminated: false }; - - case 'pairing_code_instructions': - return { state: { kind: 'awaiting_pairing_code' }, pendingTs: ts, terminated: false }; - - case 'pairing_code_displayed': { - // Anchor on the first appearance — keep firstShownAt stable across - // re-emissions in the same scan window (the bridge shouldn't - // re-emit the same code, but if it does, we don't want to reset - // the countdown). - const firstShownAt = - acc.state.kind === 'pairing_code_shown' && acc.state.firstShownAt > 0 - ? acc.state.firstShownAt - : ts; - return { - state: { - kind: 'pairing_code_shown', - code: ev.code, - firstShownAt, - }, - pendingTs: ts, - terminated: false, - }; - } - - case 'qr_displayed': { - // Same anchor logic as the live reducer. - if (acc.state.kind !== 'awaiting_qr_scan') { - return { - state: { - kind: 'awaiting_qr_scan', - qrData: ev.qrData, - qrEventId: ev.eventId, - firstShownAt: ts, - }, - pendingTs: ts, - terminated: false, - }; - } - if (ev.replacesEventId === acc.state.qrEventId) { - return { - state: { ...acc.state, qrData: ev.qrData }, - pendingTs: ts, - terminated: false, - }; - } - if (!ev.replacesEventId) { - return { - state: { - kind: 'awaiting_qr_scan', - qrData: ev.qrData, - qrEventId: ev.eventId, - firstShownAt: ts, - }, - pendingTs: ts, - terminated: false, - }; - } - return acc; - } - - case 'qr_redacted': { - if (acc.state.kind !== 'awaiting_qr_scan') return acc; - if (acc.state.qrEventId !== ev.redactsEventId) return acc; - // Move into qr_verifying and keep the chain open — login_success - // typically follows in the same scan window. - return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; - } - - case 'invalid_value': - if (!isFormState(acc.state)) return acc; - return { - state: { ...acc.state, lastError: { kind: 'invalid_value', reason: ev.reason } }, - pendingTs: ts, - terminated: false, - }; - - case 'submit_failed': - if (!isFormState(acc.state)) return acc; - return { - state: { ...acc.state, lastError: { kind: 'submit_failed', reason: ev.reason } }, - pendingTs: ts, - terminated: false, - }; - - case 'login_failed': - // `context canceled` is an echo of a previous cancel — never a - // terminal signal for the chain we're hydrating, since the chain - // can immediately re-enter via a fresh `qr_displayed` / - // `awaiting_phone` for a new flow. Treat as a no-op so the chain - // keeps walking. - if (ev.reason === 'context canceled') return acc; - return { state: acc.state, pendingTs: null, terminated: true }; - - // Terminal events — collapse the chain. State becomes whatever the - // bot confirmed last; the caller returns null and lets `list-logins` - // reconcile. - case 'login_success': - case 'logout_ok': - case 'cancel_ok': - case 'cancel_no_op': - case 'not_logged_in': - case 'max_logins': - case 'login_not_found': - case 'prepare_failed': - case 'start_failed': - case 'flow_required': - case 'flow_invalid': - case 'unknown_command': - case 'external_logout': - return { state: acc.state, pendingTs: null, terminated: true }; - - case 'logins_listed': - // A list-logins reply landed in history — terminal-ish for hydrate. - return { state: acc.state, pendingTs: null, terminated: true }; - - case 'login_in_progress': - case 'connection_warning': - case 'unknown': - // Soft no-ops for hydrate. login_in_progress is a live-flow - // warning that doesn't reflect persistent state; connection_warning - // is a transcript-only signal; unknown is a wording-drift catch-all. - return acc; - - default: { - const exhaustive: never = ev; - return exhaustive; - } - } -}; - -export const hydrateFromTimeline = ( - inputs: ReadonlyArray, - now: number = Date.now() -): HydrateRestoredState | null => { - const acc = inputs.reduce(stepHydrate, { - state: { kind: 'unknown' }, - pendingTs: null, - terminated: false, - }); - - if (acc.terminated) return null; - if (acc.pendingTs === null) return null; - if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; - if (acc.state.kind === 'qr_verifying') return acc.state; - if (acc.state.kind === 'pairing_verifying') return acc.state; - if (!isFormState(acc.state)) return null; - return acc.state; -}; - -// --- DEV sanity assertions ----------------------------------------------- - -if (import.meta.env.DEV) { - runHydrateSanity(); -} - -function runHydrateSanity(): void { - const t0 = 1_700_000_000_000; - const recent = (offset: number) => t0 + offset; - const now = t0 + 60 * 1000; - - const cases: Array<{ - name: string; - inputs: HydrateInput[]; - expected: LoginState | null; - nowOverride?: number; - }> = [ - { name: 'empty timeline → null', inputs: [], expected: null }, - { - name: 'lone phone prompt → awaiting_phone', - inputs: [{ ev: { kind: 'awaiting_phone' }, ts: recent(0) }], - expected: { kind: 'awaiting_phone' }, - }, - { - name: 'phone + pairing-code instructions → awaiting_pairing_code', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, - ], - expected: { kind: 'awaiting_pairing_code' }, - }, - { - name: 'phone + instructions + code → pairing_code_shown', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, - { - ev: { kind: 'pairing_code_displayed', code: 'ABCD-1234' }, - ts: recent(1100), - }, - ], - expected: { - kind: 'pairing_code_shown', - code: 'ABCD-1234', - firstShownAt: recent(1100), - }, - }, - { - name: 'phone + code only (instructions notice missed) → pairing_code_shown', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { - ev: { kind: 'pairing_code_displayed', code: 'WXYZ-9876' }, - ts: recent(1000), - }, - ], - expected: { - kind: 'pairing_code_shown', - code: 'WXYZ-9876', - firstShownAt: recent(1000), - }, - }, - { - name: 'lone qr_displayed → awaiting_qr_scan', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,b,c,d', eventId: '$qrA' }, - ts: recent(0), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - qrData: '2@A,b,c,d', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr rotation edits → repaint payload, keep original event id', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: recent(0), - }, - { - ev: { - kind: 'qr_displayed', - qrData: '2@B,a,b,c', - eventId: '$qrEdit1', - replacesEventId: '$qrA', - }, - ts: recent(60_000), - }, - { - ev: { - kind: 'qr_displayed', - qrData: '2@C,a,b,c', - eventId: '$qrEdit2', - replacesEventId: '$qrA', - }, - ts: recent(80_000), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - qrData: '2@C,a,b,c', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr_redacted with mismatched target → ignored', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, - ], - expected: { - kind: 'awaiting_qr_scan', - qrData: '2@A,a,b,c', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr scan → no follow-up → qr_verifying', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, - ], - expected: { kind: 'qr_verifying' }, - }, - { - name: 'qr scan → login_success → null (let list-logins reconcile)', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, - { - ev: { kind: 'login_success', handle: '+12345678901' }, - ts: recent(31000), - }, - ], - expected: null, - }, - { - name: 'cancel_ok after pending → null', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - ], - expected: null, - }, - { - name: 'not_logged_in alone → null', - inputs: [{ ev: { kind: 'not_logged_in' }, ts: recent(0) }], - expected: null, - }, - { - name: 'cancel-then-restart-mid-pairing → awaiting_pairing_code', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, - { ev: { kind: 'cancel_ok' }, ts: recent(2000) }, - { ev: { kind: 'awaiting_phone' }, ts: recent(3000) }, - { ev: { kind: 'pairing_code_instructions' }, ts: recent(4000) }, - ], - expected: { kind: 'awaiting_pairing_code' }, - }, - // Hydrate-side analog of live reducer's «late rotation after Cancel» - // guard: a rotation edit with replacesEventId in the chain after - // a cancel_ok (terminal) must NOT resurrect tracking. The terminal - // gate already handles this — only `qr_displayed` and - // `awaiting_phone` re-enter, but we explicitly only re-enter on a - // FRESH (non-edit) qr_displayed. Cover with a sanity case. - { - name: 'cancel + late rotation edit → null (no resurrect from edit)', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'cancel_ok' }, ts: recent(30_000) }, - // Late rotation edit pointing at the cancelled flow's QR. The - // hydrate accumulator is `terminated`, and re-entry only fires - // for fresh (no replacesEventId) qr_displayed. With - // replacesEventId set, the entry is ignored and we stay - // terminated → null. - { - ev: { - kind: 'qr_displayed', - qrData: '2@B,a,b,c', - eventId: '$qrEdit', - replacesEventId: '$qrA', - }, - ts: recent(31_000), - }, - ], - expected: null, - }, - { - name: 'logout-then-relogin-mid-qr → awaiting_qr_scan', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { - ev: { kind: 'login_success', handle: '+12345678901' }, - ts: recent(2000), - }, - { ev: { kind: 'logout_ok' }, ts: recent(3000) }, - { - ev: { kind: 'qr_displayed', qrData: '2@Z,a,b,c', eventId: '$qrZ' }, - ts: recent(4000), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - qrData: '2@Z,a,b,c', - qrEventId: '$qrZ', - firstShownAt: recent(4000), - }, - }, - { - name: 'pending too old (5 min) → null (3-min freshness window)', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: t0 - 5 * 60 * 1000, - }, - ], - expected: null, - nowOverride: t0, - }, - { - name: 'pending just inside window (2 min) → state', - inputs: [ - { - ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, - ts: t0 - 2 * 60 * 1000, - }, - ], - expected: { - kind: 'awaiting_qr_scan', - qrData: '2@A,a,b,c', - qrEventId: '$qrA', - firstShownAt: t0 - 2 * 60 * 1000, - }, - nowOverride: t0, - }, - { - name: 'submit_failed on phone form → keeps form with warn', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { - ev: { kind: 'submit_failed', reason: 'Phone number too short' }, - ts: recent(1000), - }, - ], - expected: { - kind: 'awaiting_phone', - lastError: { kind: 'submit_failed', reason: 'Phone number too short' }, - }, - }, - { - name: 'login_in_progress alone → null (soft no-op)', - inputs: [{ ev: { kind: 'login_in_progress' }, ts: recent(0) }], - expected: null, - }, - { - name: 'connection_warning alone → null (transcript-only)', - inputs: [ - { - ev: { kind: 'connection_warning', text: 'Reconnecting to WhatsApp...' }, - ts: recent(0), - }, - ], - expected: null, - }, - { - name: 'external_logout alone → null (terminal — let list-logins reconcile)', - inputs: [ - { - ev: { kind: 'external_logout', reason: 'phone_logged_out' }, - ts: recent(0), - }, - ], - expected: null, - }, - { - name: 'unknown alone → null', - inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }], - expected: null, - }, - ]; - - for (const c of cases) { - const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now); - if (!sameLoginState(actual, c.expected)) { - // eslint-disable-next-line no-console - console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected }); - throw new Error(`hydrate sanity failed: ${c.name}`); - } - } -} - -function sameLoginState(a: LoginState | null, b: LoginState | null): boolean { - if (a === null || b === null) return a === b; - return JSON.stringify(a) === JSON.stringify(b); -} diff --git a/apps/widget-whatsapp/src/styles.css b/apps/widget-whatsapp/src/styles.css index 3ee7dce8..032e1ee4 100644 --- a/apps/widget-whatsapp/src/styles.css +++ b/apps/widget-whatsapp/src/styles.css @@ -93,35 +93,10 @@ body { padding-top: 4px; } -/* Section label — same dark-bg pill vocabulary as `.section-status` so the - * two pieces in the section-header row read as a matched pair (label - * pill + status pill). The pill chrome wraps the existing uppercase - * letter-spaced typography; chip is non-interactive, no cursor. */ -.section-label { - display: inline-flex; - align-items: center; - font-size: 13px; - line-height: 20px; - text-transform: uppercase; - letter-spacing: 1.4px; - font-weight: 600; - color: var(--muted); - background: var(--bg2); - border: 1px solid var(--divider); - border-radius: 8px; - padding: 8px 14px; - margin: 0 0 14px; - white-space: nowrap; - user-select: none; -} - /* Status pill — button-styled but intentionally non-interactive (no - * cursor:pointer, no hover). Replaces the section header for stateful - * sections (disconnected / connected / unknown / logging_out) — the - * pill itself carries the section's identity, so a separate - * `.section-label` would just duplicate the meaning. Same dark-bg - * vocabulary (--bg2 / divider border) as `.recovery-action` and the - * host hero's «О боте» chip. */ + * cursor:pointer, no hover). The pill carries the section's identity; + * same dark-bg vocabulary (--bg2 / divider border) as `.recovery-action` + * and the host hero's «О боте» chip. */ .section-status { display: inline-flex; align-items: center; @@ -242,7 +217,15 @@ body { text-align: left; font: inherit; color: inherit; - transition: border-color 0.12s, background 0.12s; + transition: border-color 0.12s, background 0.12s, transform 0.08s ease-out; +} + +/* Press feedback — :active fires on touch and mouse alike, and unlike + * :hover the WebView never leaves it stuck after the finger lifts. Scale + * is subtle on purpose: cards are wide, a deep squash looks broken. */ +.command-card:active:not(:disabled) { + transform: scale(0.985); + background: var(--surface); } /* Hover scoped to mouse-mode sessions only. Capacitor Android WebView @@ -319,122 +302,12 @@ body { display: block; } -/* Spin the leading refresh icon while the card is in its `refreshing` - * in-flight state. Combined with `disabled` (which dims the card to - * opacity 0.5 and gates :hover via :not(:disabled)), the spinner is - * the unambiguous «I'm working» signal — no more guessing whether the - * click registered. The selector targets the leading icon slot since - * the refresh card moved its glyph from the chevron (right) to the - * lead slot (left) for parity with every other card. */ -.command-card.refreshing .command-card-lead-icon svg { - animation: command-card-spin 0.8s linear infinite; -} -@keyframes command-card-spin { - to { - transform: rotate(360deg); - } -} - -/* ── Transcript ──────────────────────────────────────────────────── */ - -.transcript { - background: var(--bg2); - border: 1px solid var(--divider); - border-radius: 10px; - padding: 12px 14px; - font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; - font-size: 12.5px; - line-height: 1.55; - max-height: 360px; - overflow-y: auto; - /* Custom scrollbar styled into the dark palette. Native browser - * scrollbars (gray, system-themed) clash with the Dawn surface. */ - scrollbar-width: thin; - scrollbar-color: var(--surface2) transparent; -} - -.transcript::-webkit-scrollbar { - width: 8px; -} -.transcript::-webkit-scrollbar-track { - background: transparent; -} -.transcript::-webkit-scrollbar-thumb { - background: var(--surface2); - border-radius: 4px; - border: 2px solid var(--bg2); - background-clip: padding-box; -} -.transcript::-webkit-scrollbar-thumb:hover { - background: var(--surface); - border: 2px solid var(--bg2); - background-clip: padding-box; -} - -.transcript-line { - padding: 4px 0; - display: flex; - gap: 10px; - align-items: flex-start; - white-space: pre-wrap; - word-break: break-word; -} - -.transcript-line + .transcript-line { - border-top: 1px dashed var(--divider); -} - -.transcript-line .ts { - color: var(--faint); - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -.transcript-line .body { - flex: 1; - min-width: 0; -} - -.transcript-line.from-bot .body { - color: var(--text); -} - -.transcript-line.from-user .body { - color: var(--fleet-soft); -} - -.transcript-line.diag .body { - color: var(--muted); -} - -.transcript-line.error .body { - color: var(--rose); -} - -.transcript-empty { - color: var(--faint); - text-align: center; - padding: 16px 0; - font-style: italic; -} - -/* Destructive card — keeps the red name to mark «Выйти из WhatsApp» as a - * destructive action, distinguishing it from the primary login card. - * Hover border stays on the generic input-gated rule (hairline) so the - * accent is consistent across touch and mouse modes. A previous - * `.command-card.danger:hover { border-color: var(--rose) }` override - * was dead in mouse mode (lower specificity than the input-gated rule) - * and only fired in the pre-first-pointerdown touch stub. */ -.command-card.danger .command-card-name { - color: var(--rose); -} - -/* Generic leading-icon slot — every command-card carries one as a - * left-side semantic glyph (mirror of the right-side chevron). The - * SVG picks up `currentColor` so it tints with the card's modifier - * — muted by default, amber inside `.command-card.warn`, rose-ish - * inheritance left intentionally OFF for `.danger` (only the title - * goes rose; the lead icon stays muted to keep one accent per card). */ +/* Generic leading-icon slot — every command-card carries a semantic + * left-side glyph (mirror of the right-side chevron). Picks up + * `currentColor` from the parent and stays muted by default; the + * `.danger` modifier on logout deliberately does NOT colour the lead + * icon so the rose accent stays reserved for the title (one accent + * per card). */ .command-card-lead-icon { flex-shrink: 0; display: inline-flex; @@ -448,24 +321,430 @@ body { display: block; } -/* Warn variant — WhatsApp-only, applied to the AboutCard so the amber - * border, amber-tinted name, and amber lead icon together signal that - * the modal behind this card carries a Meta-ToS risk disclosure (in - * addition to the «how it works» copy). The hybrid card description - * («о работе и рисках») depends on this visual cue to feel honest. */ -.command-card.warn { - background: rgba(212, 184, 138, 0.06); - border-color: var(--amber); +/* Spin the leading refresh icon while the card is in its `refreshing` + * in-flight state. Combined with `disabled` (which dims the card to + * opacity 0.5 and gates :hover via :not(:disabled)), the spinner is + * the unambiguous «I'm working» signal — no more guessing whether the + * click registered. The selector targets the lead slot since the + * refresh card moved its glyph from the chevron (right) to the lead + * slot (left) for parity with every other card. */ +.command-card.refreshing .command-card-lead-icon svg { + animation: command-card-spin 0.8s linear infinite; } -.command-card.warn .command-card-name { +@keyframes command-card-spin { + to { + transform: rotate(360deg); + } +} + +/* ── Press feedback for the smaller controls ──────────────────────── */ +/* Same rationale as .command-card:active above. Filled (violet/rose) + * buttons darken instead of swapping background so their accent reads + * as «pressed», not «replaced». */ +.recovery-action, +.icon-btn, +.btn-primary, +.btn-text, +.contact-action, +.probe-check, +.command-card-confirm-yes, +.command-card-confirm-no { + transition: transform 0.08s ease-out, background 0.12s, color 0.12s, border-color 0.12s, + filter 0.08s ease-out; +} +.recovery-action:active:not(:disabled), +.icon-btn:active:not(:disabled), +.btn-text:active:not(:disabled), +.probe-check:active:not(:disabled), +.command-card-confirm-no:active:not(:disabled) { + transform: scale(0.96); +} +.btn-primary:active:not(:disabled), +.contact-action:active:not(:disabled), +.command-card-confirm-yes:active:not(:disabled) { + transform: scale(0.96); + filter: brightness(0.85); +} + +/* ── Icon button (compact action next to a status pill / search field) ── */ + +.icon-btn { + -webkit-appearance: none; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +:root[data-input='mouse'] .icon-btn:hover:not(:disabled) { + background: var(--surface); + color: var(--text); + border-color: var(--hairline); +} +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.icon-btn svg { + width: 16px; + height: 16px; +} +/* Square back button — same chrome as .icon-btn, bigger glyph: it's the + * primary way out of the contacts sub-screen, the arrow should read at a + * glance. */ +.icon-btn-back svg { + width: 22px; + height: 22px; +} +.icon-btn.spinning svg { + animation: command-card-spin 0.8s linear infinite; +} + +/* ── Spinner (inline in-flight indicator) ─────────────────────────── */ + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: command-card-spin 0.7s linear infinite; + flex-shrink: 0; + opacity: 0.8; +} + +/* ── Notice strip (inline action feedback — replaces the transcript) ── */ + +.notice-slot { + padding: 16px var(--section-pad-x) 0; +} + +.notice { + display: flex; + align-items: flex-start; + gap: 10px; + border-radius: 10px; + border: 1px solid var(--divider); + background: var(--bg2); + padding: 10px 14px; + font-size: 13px; + line-height: 19px; + color: var(--text); + margin-bottom: 12px; +} +.notice.error { + border-color: var(--rose); + color: var(--rose); + background: rgba(192, 142, 123, 0.08); +} +.notice.warn { + border-color: var(--amber); + color: var(--amber); + background: rgba(212, 184, 138, 0.08); +} +.notice.info { + border-color: var(--green); + color: var(--green); + background: rgba(125, 211, 168, 0.08); +} +.notice-body { + flex: 1; + min-width: 0; +} +.notice-dismiss { + background: transparent; + border: none; + color: inherit; + font: inherit; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 2px; + flex-shrink: 0; + opacity: 0.7; +} +.notice-dismiss:hover { + opacity: 1; +} + +/* ── Avatar (initials on a Dawn accent) ───────────────────────────── */ + +.avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + color: #0c0c0e; + font-weight: 700; + flex-shrink: 0; + user-select: none; + overflow: hidden; +} + +/* Photo layer (MSC4039 download). Sits on top of the initials — a missing + * or still-loading photo leaves the colored-letter fallback visible. */ +.avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── Contacts surface ─────────────────────────────────────────────── */ + +.contacts { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Search shell mirrors the mock's «Быстрый запуск» row: one integrated + * input strip with a leading glyph and a trailing refresh action. */ +.search-shell { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 6px 14px; + transition: border-color 0.12s, box-shadow 0.12s; +} +.search-shell:focus-within { + border-color: var(--fleet); + box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18); +} +.search-shell-icon { + display: inline-flex; + color: var(--muted); + flex-shrink: 0; +} +.search-shell-icon svg { + width: 16px; + height: 16px; +} +.search-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--text); + font: inherit; + font-size: 15px; + padding: 8px 0; +} +.search-input::placeholder { + color: var(--faint); +} + +/* Probe affordance — explicit «check on Telegram» action for identifier- + * shaped queries. Click-gated on purpose: resolve calls have no flood-wait + * retry on the bridge side. */ +.probe-check { + -webkit-appearance: none; + appearance: none; + display: flex; + align-items: center; + gap: 10px; + background: rgba(149, 128, 255, 0.08); + border: 1px dashed var(--fleet); + border-radius: 10px; + padding: 12px 14px; + font: inherit; + font-size: 14px; + color: var(--fleet-soft); + cursor: pointer; + text-align: left; + transition: background 0.12s; +} +:root[data-input='mouse'] .probe-check:hover:not(:disabled) { + background: rgba(149, 128, 255, 0.14); +} +.probe-check:disabled { + cursor: progress; +} +.probe-check svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.probe-result { + display: flex; + flex-direction: column; + gap: 8px; +} +.probe-result-label { + font-size: 13px; + color: var(--green); +} +.probe-result-label.missing { color: var(--amber); } -.command-card.warn .command-card-lead-icon { - color: var(--amber); +.probe-result .contact-row { + border-color: var(--fleet); } -:root[data-input='mouse'] .command-card.warn:hover:not(:disabled) { - background: rgba(212, 184, 138, 0.12); - border-color: var(--amber); + +.contact-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.contact-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 10px 12px; +} + +.contact-main { + flex: 1; + min-width: 0; +} + +.contact-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.contact-name-text { + font-size: 14.5px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.contact-handle { + display: flex; + flex-wrap: wrap; + column-gap: 10px; + row-gap: 1px; + font-size: 12px; + color: var(--muted); + font-family: ui-monospace, 'JetBrains Mono', monospace; + margin-top: 2px; + min-width: 0; +} +.contact-handle-part { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.contact-action { + -webkit-appearance: none; + appearance: none; + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--fleet); + color: #0c0c0e; + border: none; + border-radius: 7px; + padding: 8px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} +.contact-action.linked { + background: transparent; + color: var(--muted); + border: 1px solid var(--divider); + font-weight: 500; +} +:root[data-input='mouse'] .contact-action.linked:hover:not(:disabled) { + color: var(--text); + border-color: var(--hairline); + background: var(--surface); +} +.contact-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.contacts-placeholder { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13.5px; + padding: 28px 12px; + text-align: center; +} +.contacts-error-text { + color: var(--rose); +} + +/* ── Account tab ──────────────────────────────────────────────────── */ + +.account-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 14px 16px; +} + +.account-main { + flex: 1; + min-width: 0; +} + +.account-name { + font-size: 16px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-handles { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 3px; + font-size: 12.5px; + color: var(--muted); + font-family: ui-monospace, 'JetBrains Mono', monospace; +} + +/* Destructive card — keeps the red name to mark «Выйти из Telegram» as a + * destructive action, distinguishing it from the primary login card. + * Hover border stays on the generic input-gated rule (hairline) so the + * accent is consistent across touch and mouse modes. A previous + * `.command-card.danger:hover { border-color: var(--rose) }` override + * was dead in mouse mode (lower specificity than the input-gated rule) + * and only fired in the pre-first-pointerdown touch stub. */ +.command-card.danger .command-card-name { + color: var(--rose); } /* Inline confirm-in-place body for the destructive logout card. The button @@ -579,6 +858,9 @@ body { .auth-input:hover:not(:focus):not(:disabled) { border-color: rgba(255, 255, 255, 0.16); } +[data-theme='light'] .auth-input:hover:not(:focus):not(:disabled) { + border-color: rgba(0, 0, 0, 0.22); +} .auth-input:focus { border-color: var(--fleet); /* Stronger ring than border-color alone — matches Dawn's emphasis on @@ -592,9 +874,10 @@ body { box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22); } -/* Soft-warn for client-side phone validation. The bridge still has the - * final say (and the cooldown self-clears on `invalid_value`), so amber - * is the right register — server-confirmed errors keep rose. */ +/* Soft-warn variant for client-side phone validation. The bridge still + * has the authoritative word (and the cooldown clears itself on + * `invalid_value`), so we use amber rather than the harder rose tone + * reserved for server-confirmed errors. */ .auth-input.warn { border-color: var(--amber); } @@ -602,10 +885,11 @@ body { box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22); } -/* Phone-input shell — host for the country-flag emoji positioned over - * the input's left padding (no need to split the input's background / - * border / focus ring across two siblings). `with-flag` bumps - * padding-left so digits clear the glyph. */ +/* Phone-input shell: lets us position a country-flag emoji over the + * input's left padding without splitting the input's own background / + * border / focus ring. The shell IS the layout flex child; the input + * fills it. `with-flag` bumps text padding-left so the digits clear + * the flag glyph. */ .auth-phone-shell { position: relative; display: flex; @@ -630,12 +914,81 @@ body { user-select: none; } -/* Note: TG-style `.auth-input.code` / `.auth-input.password` / - * `.password-row` / `.btn-icon` selectors were intentionally NOT - * carried over — WhatsApp has no SMS-code form (pairing-code is - * displayed by the bridge, not typed) and no 2FA password form - * (multidevice handshake is single-factor). If a future flow - * needs them, copy from widget-telegram styles.css. */ +.auth-input.code, +.auth-input.password { + font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; + letter-spacing: 4px; + font-size: 20px; +} + +/* Password-input shell — host for the reveal eye-button positioned over + * the input's right padding. Mirrors `.auth-phone-shell` (left flag) and + * follows the same UX as Vojo's main auth `PasswordInput` (eye toggle + * embedded in the input chrome, no sibling pill that can overflow off + * the right edge on narrow viewports). Replaces the previous + * `.password-row` flex-pair that stacked into a full-width button on + * mobile, see commit 8d8b39e8. */ +.auth-password-shell { + position: relative; + display: flex; + flex: 1; + min-width: 0; +} +.auth-password-shell .auth-input { + flex: 1; + min-width: 0; + /* Reserve room for the eye button so the password bullets don't run + * under the glyph. Eye button ≈ 36 px wide + 6 px breathing room. */ + padding-right: 44px; +} +/* Suppress the legacy Edge / IE11 native reveal glyph (`::-ms-reveal`) + * so it doesn't render on top of our own eye button. Chromium / WebKit + * / Firefox ignore this pseudo — no-op on the platforms we actually + * ship to, cheap defence for users who arrive on legacy Edge. */ +.auth-password-shell .auth-input::-ms-reveal { + display: none; +} +.auth-password-eye { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--muted); + cursor: pointer; + padding: 0; +} +.auth-password-eye:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* Hover / focus rings gated on `[data-input='mouse']` (set by main.tsx + * from `pointerdown.pointerType`) because Capacitor Android WebView lies + * about `(hover: hover)` on pure-touch devices — a media-query gate + * would still let the WebView paint a stuck `:hover` on the tapped + * button. Same reason `.command-card:hover` upstream is gated this way. */ +:root[data-input='mouse'] .auth-password-eye:hover:not(:disabled) { + color: var(--text); + background: var(--hairline); +} +:root[data-input='mouse'] .auth-password-eye:focus-visible { + outline: 2px solid var(--fleet); + outline-offset: 1px; + color: var(--text); +} +.auth-password-eye svg { + /* 16 px matches folds `` used by the canonical + * password input in `src/app/components/password-input`. */ + width: 16px; + height: 16px; +} .btn-primary { background: var(--fleet); @@ -730,6 +1083,11 @@ body { * paste-on-paper. */ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32); } +[data-theme='light'] .auth-card-qr-frame { + /* The dark-mode edge highlight is invisible on a light surface; a plain + * soft drop shadow separates the white plate instead. */ + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.14); +} /* Placeholder while we wait for the bridge's first qr_displayed event. * Same visual vocabulary as `.section-status.checking`: amber dot + muted @@ -768,129 +1126,6 @@ body { color: var(--faint); } -/* ── Pairing-code panel ───────────────────────────────────────────── - * WhatsApp's pairing-code login flow renders an 8-character code - * (`XXXX-XXXX`) that the user types into the WhatsApp mobile app under - * Settings → Linked devices → Link with phone number. The code box is - * the visual anchor — large, monospace, generous letter-spacing so the - * user can read it at arm's length. Mirrors the QR panel's vocabulary - * (centred frame, step list, countdown) but no white plate is needed - * because there's no machine-readable matrix to render at high contrast. */ - -.auth-card-pairing { - align-items: stretch; -} - -.auth-card-pairing-frame { - align-self: center; - background: var(--bg); - border: 1px solid var(--hairline); - border-radius: 12px; - padding: 24px 32px; - display: inline-flex; - align-items: center; - justify-content: center; - /* Match the QR plate's footprint roughly so the panel doesn't reflow - * dramatically when the user switches between login flows. */ - min-width: 260px; - min-height: 120px; - /* Subtle inner glow on the fleet accent — picks up the same emphasis - * vocabulary as the input :focus ring, telling the eye «this is the - * thing you act on». */ - box-shadow: inset 0 0 0 1px rgba(149, 128, 255, 0.18); -} - -.auth-card-pairing-code-text { - font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; - font-size: 32px; - font-weight: 600; - letter-spacing: 6px; - color: var(--text); - /* Tabular numerics keep the hyphen and digits aligned — without it - * proportional widths shift the centre of the code on narrow screens. */ - font-variant-numeric: tabular-nums; - user-select: all; - /* Soft text shadow tints the digits with the fleet accent without - * relying on a dedicated colour token — tone-on-tone. */ - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); -} - -/* Visually-hidden helper — used to attach a supplemental description - * (e.g. «Pairing code for WhatsApp sign-in. Enter it in the app on - * your phone.») to a `` via aria-describedby without putting - * the description in the visible UI. Standard screen-reader-only - * pattern. */ -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.auth-card-pairing-placeholder { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--muted); - font-size: 13px; - line-height: 20px; - padding: 24px 16px; -} -.auth-card-pairing-placeholder .dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--amber); - flex-shrink: 0; -} - -/* Step list under the pairing code — same vocabulary as QR steps. */ -.auth-card-pairing-steps { - margin: 0; - padding-left: 1.4em; - display: flex; - flex-direction: column; - gap: 6px; - font-size: 13px; - line-height: 19px; - color: var(--muted); -} -.auth-card-pairing-steps li::marker { - color: var(--faint); -} - -/* External-logout warn block — same shape as auth-card-warn but lifted - * to a dedicated banner that appears above the disconnected command - * grid. `external_logout` is a hard event from WhatsApp itself - * (multidevice unlink from the phone or another device), so it needs - * to be visually louder than a stale form-side warning. */ -.section-warn-banner { - display: flex; - align-items: flex-start; - gap: 10px; - background: rgba(212, 184, 138, 0.1); - border: 1px solid var(--amber); - border-radius: 10px; - padding: 12px 14px; - font-size: 13px; - line-height: 18px; - color: var(--amber); - margin-bottom: 14px; -} -.section-warn-banner .dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--amber); - flex-shrink: 0; - margin-top: 6px; -} - @media (max-width: 600px) { .auth-card-row { flex-direction: column; @@ -932,30 +1167,58 @@ body { padding: 80px 12px; } - /* Mobile pairing-code panel — keep the code legible at phone widths - * but trim the surrounding plate so it doesn't dominate. The font is - * already large; what we shrink is padding + min-size. */ - .auth-card-pairing-frame { - min-width: 232px; - padding: 18px 20px; + /* Contact rows: tighter chrome, and the action button stays inline (it's + * short — «Открыть чат» worst case) rather than wrapping full-width. */ + .contact-row { + padding: 9px 10px; + gap: 10px; } - .auth-card-pairing-code-text { - /* The 32px desktop size with 6px letter-spacing is ~290 px wide for - * `XXXX-XXXX`. Phone viewport at 360px screen − 40px section padding - * = 320px, so reduce a notch to leave room for the frame border. */ - font-size: 28px; - letter-spacing: 4px; + .contact-action { + padding: 8px 11px; + font-size: 12.5px; + } + /* Narrow viewports: @ник и телефон друг под другом вместо обрезанной + * одной строки — на телефоне рядом с кнопкой им не хватает ширины. */ + .contact-handle { + flex-direction: column; } -} -/* ── Linkified transcript bodies ─────────────────────────────────── */ - -.transcript-line a { - color: var(--fleet-soft); - text-decoration: underline; -} -.transcript-line a:hover { - color: var(--text); + /* Header row on phones: the status pill grows to fill the row at the + * search-field height (~48px), and the square icon buttons (refresh / + * back) match it, so the trio lines up edge-to-edge with the search + * shell below. Desktop keeps the compact content-sized pill. */ + .section-recovery-row { + flex-wrap: nowrap; + } + .section-recovery-row > .section-status { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + padding: 13px 16px; + } + .section-recovery-row > .icon-btn { + width: 48px; + height: 48px; + border-radius: 10px; + } + .section-recovery-row > .icon-btn svg { + width: 18px; + height: 18px; + } + .section-recovery-row > .icon-btn.icon-btn-back svg { + width: 25px; + height: 25px; + } + /* Labeled row actions (Отмена / Повторить on the transient screens) + * match the taller pill so the row reads as one piece. */ + .section-recovery-row > .recovery-action { + padding: 13px 16px; + border-radius: 10px; + white-space: nowrap; + flex-shrink: 0; + } } /* ── Hint text ───────────────────────────────────────────────────── */ @@ -1014,6 +1277,9 @@ body { * reassuring tone of the body copy itself. */ animation: about-fade 0.15s ease-out; } +[data-theme='light'] .about-overlay { + background: rgba(26, 26, 29, 0.36); +} @keyframes about-fade { from { @@ -1105,14 +1371,39 @@ body { border-top: 1px solid var(--divider); } -/* WhatsApp-only: Meta-ToS risk-disclosure callout at the top of the - * About modal body. Amber tint + border distinguish it from the plain - * paragraphs that follow — same visual language the dedicated warning - * card used to carry on the disconnected screen, now folded inline. +/* =========================================================================== + * WhatsApp-only additions below this line. Everything above is byte-for-byte + * the Telegram widget stylesheet (the shared Dawn-palette canon) — keep it + * that way so diffs against widget-telegram/src/styles.css stay reviewable. + * =========================================================================== */ + +/* Warn variant — applied to the About card so the amber border, amber-tinted + * name, and amber lead icon together signal that the modal behind this card + * carries a Meta-ToS risk disclosure (in addition to the «how it works» + * copy). The hybrid card description («о работе и рисках») depends on this + * visual cue to feel honest. Amber is mid-tone — the same rgba tints read + * fine on both themes. */ +.command-card.warn { + background: rgba(212, 184, 138, 0.06); + border-color: var(--amber); +} +.command-card.warn .command-card-name { + color: var(--amber); +} +.command-card.warn .command-card-lead-icon { + color: var(--amber); +} +:root[data-input='mouse'] .command-card.warn:hover:not(:disabled) { + background: rgba(212, 184, 138, 0.12); + border-color: var(--amber); +} + +/* Meta-ToS risk-disclosure callout at the top of the About modal body. + * Amber tint + border distinguish it from the plain paragraphs that follow. * * Body text inside the callout still picks up the about-body's - * `.about-body p` rule (default text colour), which is intentional: - * only the title is amber-tinted, the paragraphs stay readable. */ + * `.about-body p` rule (default text colour), which is intentional: only + * the title is amber-tinted, the paragraphs stay readable. */ .about-warn-callout { background: rgba(212, 184, 138, 0.08); border: 1px solid var(--amber); @@ -1150,3 +1441,40 @@ body { margin: 0; line-height: 1.3; } + +/* Pairing-code plate — the second display_and_wait type. Reuses the QR + * panel's layout skeleton (.auth-card-qr + countdown + steps); only the + * payload presentation differs: a large monospace 8-character code the user + * types into the WhatsApp app on the phone. Surface-tinted rather than the + * QR's white plate — there is nothing to scan here. */ +.auth-card-code-frame { + align-self: center; + background: var(--surface2); + border: 1px solid var(--hairline); + border-radius: 12px; + padding: 18px 26px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 260px; +} + +.auth-card-code-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 32px; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--text); + user-select: text; + white-space: nowrap; +} + +@media (max-width: 480px) { + .auth-card-code-frame { + min-width: 232px; + padding: 14px 16px; + } + .auth-card-code-value { + font-size: 26px; + } +} diff --git a/apps/widget-whatsapp/src/ui.tsx b/apps/widget-whatsapp/src/ui.tsx new file mode 100644 index 00000000..59b1e482 --- /dev/null +++ b/apps/widget-whatsapp/src/ui.tsx @@ -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 = () => ( + +); + +// 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 = () => ( + +); + +export const PhoneIcon = () => ( + +); + +export const QrIcon = () => ( + +); + +export const SearchIcon = () => ( + +); + +export const BackIcon = () => ( + +); + +export const UserIcon = () => ( + +); + +export const LogoutIcon = () => ( + +); + +// --- 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) => ( + +); + +// --- 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) => ( + +); + +// --- Status / notice -------------------------------------------------------- + +type StatusPillProps = { + tone: 'connected' | 'disconnected' | 'checking'; + children: ComponentChildren; +}; + +export const StatusPill = ({ tone, children }: StatusPillProps) => ( + + + {children} + +); + +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) => ( +
+ {children} + {onDismiss ? ( + + ) : null} +
+); + +export const Spinner = () =>
` clicks — the WebView - // doesn't have a multi-window concept, and the host's global - // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks - // inside the host document, not inside the iframe (cross-origin - // events don't bubble across the frame boundary). The widget posts - // this message instead; the host calls `openExternalUrl(url)` which - // routes to `Browser.open` on native and `window.open` on web. + // ClientWidgetApi's request/response machinery. Needed because + // cross-origin iframes inside Capacitor's Android WebView silently drop + // `` clicks. public openExternalUrl(url: string): void { + this.postSideChannel('open-external-url', { url }); + } + + // Ask the host to navigate to a Matrix room. The host validates the URL + // through `parseMatrixToRoom` and picks the destination surface (bridge + // space in Каналы / /direct/) — see BotWidgetMount.handleOpenMatrixToRoom. + public openMatrixRoom(roomId: string): void { + this.postSideChannel('open-matrix-to', { + url: `https://matrix.to/#/${encodeURIComponent(roomId)}`, + }); + } + + private postSideChannel(action: string, data: Record): void { window.parent.postMessage( - { - api: 'io.vojo.bot-widget', - action: 'open-external-url', - data: { url }, - }, + { api: 'io.vojo.bot-widget', action, data }, this.bootstrap.parentOrigin ); } - // Always prefix outbound commands with ` ` (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 { - const data: Record = { - 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( event: K, ...args: Parameters @@ -190,11 +200,10 @@ export class WidgetApi { if (ev.origin !== this.bootstrap.parentOrigin) return; // Source-window guard: every legit widget API message comes from the // host window that embedded our iframe — i.e. window.parent. A foreign - // tab/frame on the same origin (think browser extension content - // script, popup, or sibling iframe) could otherwise post a forged - // message that passes the origin check. We only accept messages - // whose `source` is literally `window.parent`. The `widgetId` check - // a few lines down is a soft filter; this is the hard one. + // tab/frame on the same origin (browser extension content script, + // popup, sibling iframe) could otherwise post a forged message that + // passes the origin check. The `widgetId` check below is a soft + // filter; this is the hard one. if (ev.source !== window.parent) return; const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined; if (!msg || typeof msg !== 'object') return; @@ -230,7 +239,11 @@ export class WidgetApi { if (!msg.requestId || !msg.action) return; switch (msg.action) { case 'capabilities': { - this.replyTo(msg, { capabilities: this.capabilities }); + // No MSC2762 capabilities — no timeline reads, no message sends. The + // only capability the HTTP-transport widget needs is MSC4039 media + // download, used for contact/profile avatar thumbnails (media on the + // homeserver is authenticated and the widget holds no Matrix token). + this.replyTo(msg, { capabilities: ['org.matrix.msc4039.download_file'] }); return; } case 'notify_capabilities': { @@ -242,7 +255,7 @@ export class WidgetApi { return; } case 'supported_api_versions': { - this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] }); + this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc1960'] }); return; } case 'theme_change': { @@ -252,30 +265,22 @@ export class WidgetApi { this.replyTo(msg, {}); return; } - case 'send_event': { - // Live event push from host. Forward `m.room.message` (carries the - // bot's notices / errors / `m.image` QR-login broadcasts AND the - // pairing-code text) AND `m.room.redaction` (post-scan QR cleanup, - // see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State - // events (m.room.member) also arrive on this channel — we still - // ignore them here. - const data = msg.data as Partial | undefined; - if ( - data && - data.event_id && - (data.type === 'm.room.message' || data.type === 'm.room.redaction') - ) { - this.emit('liveEvent', data as RoomEvent); + case 'openid_credentials': { + // Phase-2 MSC1960 delivery after a `state: "request"` reply. + const originalId = msg.data?.original_request_id; + if (typeof originalId === 'string') { + const waiter = this.pendingOpenId.get(originalId); + if (waiter) { + this.pendingOpenId.delete(originalId); + window.clearTimeout(waiter.timer); + const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null; + if (creds) waiter.resolve(creds); + else waiter.reject(new Error('OpenID request blocked by host')); + } } this.replyTo(msg, {}); return; } - case 'update_state': { - // Initial room state push from host (m.room.member members). - // We don't use these yet; future milestones can use it for header chrome. - this.replyTo(msg, {}); - return; - } default: { // Be liberal — reply empty so the host's request promise resolves. this.replyTo(msg, {}); @@ -285,10 +290,10 @@ export class WidgetApi { private fromWidget( action: string, - data: Record + data: Record, + requestId = this.nextRequestId() ): Promise> { return new Promise((resolve, reject) => { - const requestId = this.nextRequestId(); this.pending.set(requestId, { resolve, reject }); this.postToHost({ api: 'fromWidget', @@ -306,22 +311,3 @@ export class WidgetApi { }); } } - -// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities. -// Anything else is silently dropped by the host's validateCapabilities. -// -// `m.image` and `m.room.redaction` are the QR-login additions (already in -// place from the Telegram widget M13). The host sanitizer for `m.image` -// strips `url` / `file` / `info`, leaving only `body` (the bridge encodes -// the QR payload there) plus `m.relates_to` / `m.new_content` for QR -// rotation edits. Redactions signal that the QR was consumed by a -// successful scan. -export const buildCapabilities = (roomId: string): Capability[] => [ - `org.matrix.msc2762.timeline:${roomId}`, - 'org.matrix.msc2762.send.event:m.room.message#m.text', - 'org.matrix.msc2762.receive.event:m.room.message#m.text', - 'org.matrix.msc2762.receive.event:m.room.message#m.notice', - 'org.matrix.msc2762.receive.event:m.room.message#m.image', - 'org.matrix.msc2762.receive.event:m.room.redaction', - 'org.matrix.msc2762.receive.state_event:m.room.member', -]; diff --git a/config.json b/config.json index 0711e5b8..6dfd8ba5 100644 --- a/config.json +++ b/config.json @@ -51,7 +51,8 @@ "experience": { "type": "matrix-widget", "url": "https://widgets.vojo.chat/whatsapp/index.html", - "commandPrefix": "!wa" + "provisioningUrl": "https://vojo.chat/_provision/whatsapp", + "capabilities": ["vojo.openid"] } } ],