diff --git a/apps/widget-discord/src/App.tsx b/apps/widget-discord/src/App.tsx index a13ab00a..0743747f 100644 --- a/apps/widget-discord/src/App.tsx +++ b/apps/widget-discord/src/App.tsx @@ -445,6 +445,27 @@ export function App({ bootstrap, api }: Props) { document.documentElement.dataset.theme = theme; }, [theme]); + // Capture-phase click interceptor for `` — + // inside Capacitor's Android WebView, cross-origin iframes silently + // drop those clicks (the WebView has no multi-window concept and + // the host's setupExternalLinkHandler can't see them across the + // origin boundary). We preventDefault and ask the host to open the + // URL via Browser.open / window.open. Modifier-clicks pass through + // untouched so users can still Ctrl/Cmd-click for new-tab on web. + useEffect(() => { + const onClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement | null)?.closest?.( + 'a[target="_blank"]' + ) as HTMLAnchorElement | null; + if (!anchor?.href) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + api.openExternalUrl(anchor.href); + }; + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, [api]); + const append = useCallback((line: Omit) => { setTranscript((prev) => { const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; diff --git a/apps/widget-discord/src/widget-api.ts b/apps/widget-discord/src/widget-api.ts index 8ac47aae..c88e1469 100644 --- a/apps/widget-discord/src/widget-api.ts +++ b/apps/widget-discord/src/widget-api.ts @@ -101,6 +101,30 @@ export class WidgetApi { }) as Promise<{ event_id: string }>; } + // Open an external URL via the host. The host receives this on a + // SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from + // matrix-widget-api's `fromWidget` so it doesn't route through + // ClientWidgetApi's request/response machinery. + // + // Why this exists: cross-origin iframes inside Capacitor's Android + // WebView silently drop `` clicks — the WebView + // doesn't have a multi-window concept, and the host's global + // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks + // inside the host document, not inside the iframe (cross-origin + // events don't bubble across the frame boundary). The widget posts + // this message instead; the host calls `openExternalUrl(url)` which + // routes to `Browser.open` on native and `window.open` on web. + public openExternalUrl(url: string): void { + window.parent.postMessage( + { + api: 'io.vojo.bot-widget', + action: 'open-external-url', + data: { url }, + }, + this.bootstrap.parentOrigin + ); + } + // Always prefix outbound commands with ` ` (trailing space). // Legacy mautrix-discord routes management-room commands through the // bridge.commands.Processor in mautrix/go bridge/commands; outside the diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 45b6eb6d..bb410212 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -856,6 +856,27 @@ export function App({ bootstrap, api }: Props) { document.documentElement.dataset.theme = theme; }, [theme]); + // Capture-phase click interceptor for `` — + // inside Capacitor's Android WebView, cross-origin iframes silently + // drop those clicks (the WebView has no multi-window concept and + // the host's setupExternalLinkHandler can't see them across the + // origin boundary). We preventDefault and ask the host to open the + // URL via Browser.open / window.open. Modifier-clicks pass through + // untouched so users can still Ctrl/Cmd-click for new-tab on web. + useEffect(() => { + const onClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement | null)?.closest?.( + 'a[target="_blank"]' + ) as HTMLAnchorElement | null; + if (!anchor?.href) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + api.openExternalUrl(anchor.href); + }; + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, [api]); + const append = useCallback((line: Omit) => { setTranscript((prev) => { const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; diff --git a/apps/widget-telegram/src/widget-api.ts b/apps/widget-telegram/src/widget-api.ts index 9730e1a0..f0cae80b 100644 --- a/apps/widget-telegram/src/widget-api.ts +++ b/apps/widget-telegram/src/widget-api.ts @@ -105,6 +105,30 @@ export class WidgetApi { }) as Promise<{ event_id: string }>; } + // Open an external URL via the host. The host receives this on a + // SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from + // matrix-widget-api's `fromWidget` so it doesn't route through + // ClientWidgetApi's request/response machinery. + // + // Why this exists: cross-origin iframes inside Capacitor's Android + // WebView silently drop `` clicks — the WebView + // doesn't have a multi-window concept, and the host's global + // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks + // inside the host document, not inside the iframe (cross-origin + // events don't bubble across the frame boundary). The widget posts + // this message instead; the host calls `openExternalUrl(url)` which + // routes to `Browser.open` on native and `window.open` on web. + public openExternalUrl(url: string): void { + window.parent.postMessage( + { + api: 'io.vojo.bot-widget', + action: 'open-external-url', + data: { url }, + }, + 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. diff --git a/apps/widget-whatsapp/README.md b/apps/widget-whatsapp/README.md new file mode 100644 index 00000000..c219e70e --- /dev/null +++ b/apps/widget-whatsapp/README.md @@ -0,0 +1,137 @@ +# @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. + +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. + +## 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 +``` + +## 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 + +```bash +cd apps/widget-whatsapp && npm install +cd /home/ubuntu/projects/vojo/cinny && cat > config.local.json <<'JSON' +{ + "bots": [ + { "id": "whatsapp", "experience": { "type": "matrix-widget", "url": "http://localhost:8083/" } } + ] +} +JSON +``` + +Run both servers: + +```bash +# terminal 1 — widget on :8083 with HMR +cd apps/widget-whatsapp && npm run dev + +# terminal 2 — host SPA on :8080 +cd /home/ubuntu/projects/vojo/cinny && npm start +``` + +Open `http://localhost:8080/bots/whatsapp`. The host's URL validator +accepts `http://localhost:*` only in dev builds. + +## Build + +```bash +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. + +## Capacitor (Android) + +`capacitor.config.ts` already allows `widgets.vojo.chat` for the existing +TG / Discord widgets — no extra entry needed for WhatsApp. + +## Hosting (server-side) + +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`. + +## Source-of-truth pointers + +- 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): + + +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. diff --git a/apps/widget-whatsapp/index.html b/apps/widget-whatsapp/index.html new file mode 100644 index 00000000..ad350021 --- /dev/null +++ b/apps/widget-whatsapp/index.html @@ -0,0 +1,12 @@ + + + + + + WhatsApp bridge — Vojo + + +
+ + + diff --git a/apps/widget-whatsapp/package-lock.json b/apps/widget-whatsapp/package-lock.json new file mode 100644 index 00000000..9716ad62 --- /dev/null +++ b/apps/widget-whatsapp/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "@vojo/widget-whatsapp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@vojo/widget-whatsapp", + "version": "0.0.1", + "dependencies": { + "preact": "10.22.1", + "qrcode-generator": "1.4.4" + }, + "devDependencies": { + "@preact/preset-vite": "2.9.0", + "typescript": "5.4.5", + "vite": "5.4.19" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.9.0.tgz", + "integrity": "sha512-B9yVT7AkR6owrt84K3pLNyaKSvlioKdw65VqE/zMiR6HMovPekpsrwBNs5DJhBFEd5cvLMtCjHNHZ9P7Oblveg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@prefresh/vite": "^2.4.1", + "@rollup/pluginutils": "^4.1.1", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.3.4", + "kolorist": "^1.8.0", + "magic-string": "0.30.5", + "node-html-parser": "^6.1.10", + "resolve": "^1.22.8", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.22.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.22.1.tgz", + "integrity": "sha512-jRYbDDgMpIb5LHq3hkI0bbl+l/TQ9UnkdQ0ww+lp+4MMOdqaUYdFc5qeyP+IV8FAd/2Em7drVPeKdQxsiWCf/A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/apps/widget-whatsapp/package.json b/apps/widget-whatsapp/package.json new file mode 100644 index 00000000..59b8d648 --- /dev/null +++ b/apps/widget-whatsapp/package.json @@ -0,0 +1,21 @@ +{ + "name": "@vojo/widget-whatsapp", + "version": "0.0.1", + "private": true, + "description": "Vojo WhatsApp bridge management widget — mounts inside /bots/whatsapp", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "10.22.1", + "qrcode-generator": "1.4.4" + }, + "devDependencies": { + "@preact/preset-vite": "2.9.0", + "typescript": "5.4.5", + "vite": "5.4.19" + } +} diff --git a/apps/widget-whatsapp/src/App.tsx b/apps/widget-whatsapp/src/App.tsx new file mode 100644 index 00000000..21726cb9 --- /dev/null +++ b/apps/widget-whatsapp/src/App.tsx @@ -0,0 +1,1487 @@ +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'; +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 { + 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; +}; + +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. + api: WidgetApi; +}; + +const TRANSCRIPT_MAX = 200; + +// 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; + +// Inline SVG refresh icon — shared with TG / Discord widgets. +const RefreshIcon = () => ( + +); + +// 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}`; +}; + +// 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; +}; + +// 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; + +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(''); + 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(); + }, []); + + const onSubmit = async (event: Event) => { + event.preventDefault(); + const trimmed = value.trim(); + if (!trimmed || submitting || inCooldown) return; + setSubmitting(true); + dispatch({ kind: 'submit_phone' }); + try { + await send(trimmed); + // 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 || value.trim() === ''; + const submitLabel = inCooldown + ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) }) + : t('auth-card.phone.submit'); + + return ( +
+
{t('auth-card.phone.title')}
+ +
+ { + // Auto-prepend `+` so the user never has to remember to type + // it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error + // fires for anything without a leading `+` (whatsmeow + // PairPhone's validator). Skipping locale-specific + // formatting (8→+7 etc.) keeps the rule single-line. + // + // trimStart on the raw input so that a paste of « +12345…» + // (some clipboard sources include a leading space) still + // resolves to a single `+`, instead of producing the + // double-prefix `+ +12345…` bridgev2 then rejects. + const raw = (e.currentTarget as HTMLInputElement).value.trimStart(); + setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw); + }} + disabled={submitting} + /> + + +
+
{t('auth-card.phone.hint')}
+ {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; +}; + +const AboutCard = ({ t, onOpen }: AboutCardProps) => ( + +); + +type AboutModalProps = { + t: T; + onClose: () => void; +}; + +const AboutModal = ({ t, onClose }: AboutModalProps) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( + + ); +}; + +// -------------------------------------------------------------------------- +// Warning card + modal (WhatsApp-specific Meta-ToS risk disclosure) +// -------------------------------------------------------------------------- +// +// WhatsApp is the only bot in the Vojo catalog that carries a real +// account-loss risk for the user. Meta's WhatsApp ToS forbids +// connecting an account through unofficial clients (mautrix-whatsapp +// uses the same multi-device API as WhatsApp Web — technically +// standard, but Meta may treat it as a violation). Enforcement is +// unpredictable. Users who don't understand this risk before clicking +// «Войти» can lose their primary messenger. +// +// Two surfaces: +// 1. A dedicated card on the disconnected screen above the login +// cards. Amber tone, ⚠ glyph in the title — visible even if the +// user skips the About modal. +// 2. A short callout at the top of the About modal (handled in +// AboutModal above) so users who open About also see it. + +// Inline triangle warning glyph. Stroke-only, picks up `currentColor` +// from the parent so it tints with the amber accent on the card. +const WarningIcon = () => ( + +); + +type WarningCardProps = { + t: T; + onOpen: () => void; +}; + +const WarningCard = ({ t, onOpen }: WarningCardProps) => ( + +); + +type WarningModalProps = { + t: T; + onClose: () => void; +}; + +const WarningModal = ({ t, onClose }: WarningModalProps) => { + // Same Escape + backdrop-click pattern as AboutModal — no focus-trap + // library because the surface is small. The visible header glyph + // makes the modal's purpose unambiguous. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( + + ); +}; + +// -------------------------------------------------------------------------- +// Logout card with confirm-in-place +// -------------------------------------------------------------------------- + +type LogoutCardProps = { + loginId: string | undefined; + t: T; + onConfirm: (loginId: string) => Promise; +}; + +const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + const inFlight = useRef(false); + + if (confirming) { + return ( +
+
+ {t('card.logout.confirm-prompt')} + + +
+
+ ); + } + + return ( + + ); +}; + +// -------------------------------------------------------------------------- +// Main App +// -------------------------------------------------------------------------- + +export function App({ bootstrap, api }: Props) { + const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); + const [transcript, setTranscript] = useState([]); + const [handshakeOk, setHandshakeOk] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + const [warningOpen, setWarningOpen] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const seenEventIds = useRef(new Set()); + const [state, dispatch] = useReducer(loginReducer, initialLoginState); + + // Mirror latest state for async callbacks (live event listeners attached + // once at mount). Used for the QR-redaction transcript gate (only show + // «QR использован» when the redaction targets the active QR). + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + // Capture-phase click interceptor for `` — + // inside Capacitor's Android WebView, cross-origin iframes silently + // drop those clicks (the WebView has no multi-window concept and + // the host's setupExternalLinkHandler can't see them across the + // origin boundary). We preventDefault and ask the host to open the + // URL via Browser.open / window.open. The host's web-side fallback + // (allow-popups in the iframe sandbox) still works without us, but + // routing every click through the host gives one consistent code + // path for both web and native instead of two race-prone ones. + useEffect(() => { + const onClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement | null)?.closest?.( + 'a[target="_blank"]' + ) as HTMLAnchorElement | null; + if (!anchor?.href) return; + // Allow modifier-clicks (Ctrl/Cmd-click → open in background tab, + // Shift-click → new window) to keep their browser-native + // behaviour on web. preventDefault would override these. + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + api.openExternalUrl(anchor.href); + }; + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, [api]); + + const append = useCallback((line: Omit) => { + setTranscript((prev) => { + const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; + return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next; + }); + }, []); + + // Newest-at-top ordering: pin scroll to TOP whenever a new line lands. + const transcriptRef = useRef(null); + useEffect(() => { + const el = transcriptRef.current; + if (!el) return; + el.scrollTop = 0; + }, [transcript.length]); + + // App-level cooldown state for phone-form Send Code button. + const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null); + + // Clear the cooldown across two distinct triggers: + // 1. Bridge rejected the phone number client-side (`invalid_value`) + // — the value was rejected at validate-time, BEFORE the WhatsApp + // dispatch, so no rate-limited capacity was used. Punishing the + // user for a typo would be wrong. The cooldown stays in place + // for `submit_failed` (WhatsApp-side rate limit etc.). + // 2. State left awaiting_phone via cancel / logout / login_success + // — the previous flow ended; a fresh login attempt is a fresh + // conversation (functional review #9). Clearing on every + // non-form / non-pairing-form state covers that without + // enumerating individual transitions. + useEffect(() => { + if (phoneCooldownEnd === null) return; + const formStillOpen = + state.kind === 'awaiting_phone' || + state.kind === 'awaiting_pairing_code' || + state.kind === 'pairing_code_shown'; + const phoneInvalidValue = + state.kind === 'awaiting_phone' && state.lastError?.kind === 'invalid_value'; + if (!formStillOpen || phoneInvalidValue) { + setPhoneCooldownEnd(null); + } + }, [state, phoneCooldownEnd]); + + useEffect(() => { + let disposed = false; + + api.on('ready', () => { + setHandshakeOk(true); + append({ kind: 'diag', text: t('diag.ready') }); + append({ kind: 'diag', text: t('diag.checking-status') }); + + void (async () => { + // Timeline-resume scan: read recent history BEFORE firing + // list-logins so reload-mid-flow restores the pending form + // (phone, pairing-code, QR) the user actually had open. + let hydrated = false; + try { + // Promise.allSettled (not all): one stream's failure must not + // take down the others. The notice path is the most useful + // single source — it carries phone-prompt, pairing-code + // instructions, the code itself, and login-success confirms. + const settled = await Promise.allSettled([ + api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }), + // QR images: whatsmeow rotates ~every 20 s after the first + // 60 s. Total active window 2 min 40 s = 6 events. Limit 50 + // gives plenty of headroom for slower rotations and + // out-of-order delivery. + api.readTimeline({ limit: 50, type: 'm.room.message', msgtype: 'm.image' }), + api.readTimeline({ limit: 10, type: 'm.room.redaction' }), + ]); + if (disposed) return; + const pickValue = (s: PromiseSettledResult): RoomEvent[] => + s.status === 'fulfilled' ? s.value : []; + const notices = pickValue(settled[0]); + const qrImages = pickValue(settled[1]); + const redactions = pickValue(settled[2]); + + const fromBot = (events: RoomEvent[]) => + events.filter((e) => e.sender === bootstrap.botMxid); + // Sort by origin_server_ts ascending, tie-break on event_id — + // see widget-telegram for full rationale of deterministic + // tie-breaking on simultaneous events from different streams. + const merged = [...fromBot(notices), ...fromBot(qrImages), ...fromBot(redactions)].sort( + (a, b) => { + const tsDiff = a.origin_server_ts - b.origin_server_ts; + if (tsDiff !== 0) return tsDiff; + return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0; + } + ); + + const inputs: HydrateInput[] = merged.map((e) => ({ + ev: parseEvent(e), + ts: e.origin_server_ts, + })); + const restored = hydrateFromTimeline(inputs); + + if (restored) { + // Conservative transcript replay. Body for m.image is the + // raw whatsmeow QR payload (login secret) — replaced with a + // generic «QR обновлён» diag. Body for m.notice carrying a + // pairing-code is replaced with a generic «Код для входа + // выдан» diag for the same defence-in-depth reason: even + // though the code is shown in the panel, the transcript + // shouldn't carry a copy that survives a code rotation. + // m.text user echoes are NOT replayed (would resurface the + // user's phone number from history). + // + // Dedupe via seenEventIds: a live event for the same + // notice/image/redaction may already have arrived during + // the readTimeline await. + let appendedAnyHistory = false; + const seenQrIds = new Set(); + for (const e of merged) { + if (seenEventIds.current.has(e.event_id)) continue; + seenEventIds.current.add(e.event_id); + const parsed = parseEvent(e); + if (parsed.kind === 'qr_displayed') { + seenQrIds.add(parsed.eventId); + if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId); + append({ kind: 'diag', text: t('diag.qr-issued') }); + appendedAnyHistory = true; + } else if (parsed.kind === 'qr_redacted') { + if (seenQrIds.has(parsed.redactsEventId)) { + append({ kind: 'diag', text: t('diag.qr-consumed') }); + appendedAnyHistory = true; + } + } else if (parsed.kind === 'pairing_code_displayed') { + // Don't echo the code itself — the panel handles + // display, transcript stays neutral. + append({ kind: 'diag', text: t('diag.pairing-code-issued') }); + appendedAnyHistory = true; + } else if (parsed.kind === 'connection_warning') { + append({ + kind: 'diag', + text: t('diag.connection-warning', { text: parsed.text }), + }); + appendedAnyHistory = true; + } else if (parsed.kind === 'external_logout') { + append({ kind: 'error', text: t('diag.external-logout') }); + appendedAnyHistory = true; + } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { + // m.text / m.notice — body is safe to replay verbatim + // AFTER scrubbing any QR-shaped substring (defence-in- + // depth: a future bridge could in theory leak the + // payload into a notice). + const bodyRaw = e.content.body ?? ''; + append({ kind: 'from-bot', text: `← ${scrubLoginSecret(bodyRaw)}` }); + appendedAnyHistory = true; + } + } + if (appendedAnyHistory) { + append({ kind: 'diag', text: t('diag.history-marker') }); + } + + dispatch({ kind: 'hydrate', state: restored }); + hydrated = true; + } + } catch { + if (!disposed) { + append({ kind: 'diag', text: t('diag.history-unavailable') }); + } + } + + if (disposed) return; + if (!hydrated) { + api.sendCommand('list-logins').catch((err) => { + if (disposed) return; + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + }); + } + })(); + }); + + api.on('themeChange', (name) => setTheme(name)); + + api.on('liveEvent', (ev: RoomEvent) => { + if (seenEventIds.current.has(ev.event_id)) return; + seenEventIds.current.add(ev.event_id); + // Sender filter — the strict 1:1 invariant already pins the only + // senders to user + bot, but anchoring on bootstrap.botMxid covers + // (a) skipping our own outbound echoes (we append optimistically + // with masking) and (b) defence-in-depth against any third-party + // noise. + if (ev.sender !== bootstrap.botMxid) return; + + const event = parseEvent(ev); + + // Transcript routing GATED on parser verdict, not raw event type. + if (event.kind === 'qr_displayed') { + append({ kind: 'diag', text: t('diag.qr-issued') }); + } else if (event.kind === 'qr_redacted') { + const liveState = stateRef.current; + if ( + liveState.kind === 'awaiting_qr_scan' && + liveState.qrEventId === event.redactsEventId + ) { + append({ kind: 'diag', text: t('diag.qr-consumed') }); + } + } else if (event.kind === 'pairing_code_displayed') { + // Same scrubbing principle as QR — never put the code body + // verbatim into the transcript. Panel renders the code; here + // we just log a neutral diag. + append({ kind: 'diag', text: t('diag.pairing-code-issued') }); + } else if (event.kind === 'connection_warning') { + // Bridge connection hiccup — surface verbatim wording so the + // user can see what happened, but DON'T touch state. + append({ + kind: 'diag', + text: t('diag.connection-warning', { text: event.text }), + }); + } else if (event.kind === 'external_logout') { + // Hard transition (handled by reducer below) + a louder + // transcript echo than ordinary diag lines. + append({ kind: 'error', text: t('diag.external-logout') }); + } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { + const body = ev.content.body ?? ''; + append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` }); + } + + dispatch({ kind: 'event', event }); + + // After a fresh login_success the bridge's success line doesn't + // include the loginId. Re-run list-logins so the reducer's + // `connected` state can pick up the loginId for the future + // logout call. + if (event.kind === 'login_success') { + api.sendCommand('list-logins').catch(() => { + /* connected hero still works without a loginId until the + user clicks logout; the gated tooltip guides them */ + }); + } + }); + + append({ kind: 'diag', text: t('diag.connecting') }); + + return () => { + disposed = true; + api.dispose(); + }; + // `api`, `bootstrap`, `t`, and `append` are stable for App's + // lifetime; the effect intentionally runs once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Outbound command + transcript echo. Errors are appended AND rethrown + // — callers decide whether to roll back optimistic state transitions. + const send = useCallback( + async (body: string): Promise => { + append({ kind: 'from-user', text: `→ ${body}` }); + try { + await api.sendCommand(body); + } catch (err) { + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + throw err; + } + }, + [api, append, t] + ); + + const sendBare = useCallback( + async (command: string): Promise => { + append({ kind: 'from-user', text: `→ ${command}` }); + try { + await api.sendCommand(command); + } catch (err) { + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + throw err; + } + }, + [api, append, t] + ); + + const sendCancel = useCallback(async () => { + dispatch({ kind: 'cancel_pending' }); + try { + await sendBare('cancel'); + } catch { + /* already showing disconnected; transcript carries the failure */ + } + }, [sendBare]); + + // In-flight guard against double-tap. The buttons are on the + // disconnected screen which unmounts as soon as state advances, BUT a + // rapid second click can fire in the microtask window between dispatch + // and the next React commit (especially on Android WebView). + const loginInFlight = useRef(false); + + // QR-flow: optimistic awaiting_qr_scan + rollback on send failure. + const onClickLoginQr = useCallback(async () => { + if (loginInFlight.current) return; + loginInFlight.current = true; + dispatch({ kind: 'start_qr_login' }); + try { + // Send the FULL command — bare `!wa login` would trigger + // flow_required because WhatsApp has 2 flows. + await sendBare('login qr'); + } catch { + dispatch({ kind: 'cancel_pending' }); + } finally { + loginInFlight.current = false; + } + }, [sendBare]); + + // Phone-flow (pairing-code): optimistic awaiting_phone + rollback. + const onClickLoginPairing = useCallback(async () => { + if (loginInFlight.current) return; + loginInFlight.current = true; + dispatch({ kind: 'start_phone_login' }); + try { + await sendBare('login phone'); + } catch { + dispatch({ kind: 'cancel_pending' }); + } finally { + loginInFlight.current = false; + } + }, [sendBare]); + + const onClickRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const start = Date.now(); + try { + await sendBare('list-logins'); + } catch { + /* transcript carries the failure */ + } + // 500 ms minimum visible loading state — matches TG widget rationale. + const elapsed = Date.now() - start; + if (elapsed < 500) { + await new Promise((resolve) => { + window.setTimeout(resolve, 500 - elapsed); + }); + } + setRefreshing(false); + }, [refreshing, sendBare]); + + const onConfirmLogout = useCallback( + async (loginId: string) => { + dispatch({ kind: 'request_logout', loginId }); + try { + await sendBare(`logout ${loginId}`); + } catch { + sendBare('list-logins').catch(() => { + /* nothing more we can do — user can hit refresh */ + }); + } + }, + [sendBare] + ); + + const formProps: FormProps = { + state, + t, + dispatch, + send, + sendCancel, + phoneCooldownEnd, + setPhoneCooldownEnd, + }; + + // Disconnected-screen warning banner. Shows whenever we land in + // `disconnected` with a structured lastError — gives the user a + // visible explanation instead of an unexplained return to the + // command grid (functional review #22: a QR-window timeout + // surfaces as `login_failed: Entering code or scanning QR timed + // out. Please try again.` and without this banner the user sees + // only the disappearance of the QR panel and no «why»). + // + // external_logout is the loudest case (the bridge is genuinely + // gone), so it stays at amber tone. Other lastError shapes + // (login_failed / start_failed / prepare_failed / max_logins / + // unknown_command) get the same banner — they're equally worth + // surfacing, and a unified affordance is simpler than per-class + // chrome. + const disconnectedBanner = + state.kind === 'disconnected' && state.lastError ? ( +
+ + {localizeError(state.lastError, t)} +
+ ) : null; + + return ( +
+ {handshakeOk && state.kind === 'unknown' ? ( +
+
+ + + {t('status.unknown')} + + +
+
+ ) : null} + + {handshakeOk && state.kind === 'disconnected' ? ( +
+ {disconnectedBanner} + + + {t('status.disconnected')} + +
+ {/* Warning card sits FIRST. Sized like the other cards + * (no full-width spanning) so the disconnected screen + * reads as a single uniform grid; the amber tint + ⚠ + * icon carry the visual emphasis instead. */} + setWarningOpen(true)} /> + {/* Login order mirrors the Telegram widget: phone-flow + * («Войти по номеру») first, QR second. Both are valid + * primary paths; phone-flow is the more familiar entry + * point for users coming from Telegram or used to + * SMS-style codes, so it leads. */} + + + + setAboutOpen(true)} /> +
+
+ ) : null} + + {state.kind === 'awaiting_phone' ? ( +
+ +
+ ) : null} + {state.kind === 'awaiting_pairing_code' ? ( +
+
+ + + {t('auth-card.pairing-code.preparing')} + + +
+
+ ) : null} + {state.kind === 'pairing_code_shown' ? ( +
+ +
+ ) : null} + {state.kind === 'awaiting_qr_scan' ? ( +
+ +
+ ) : null} + {/* `pairing_verifying` is reserved-but-unreachable from the live + * reducer today — the bridge does not redact the pairing-code on + * success. Hydrate could in principle restore it (and would, if a + * future bridge ever adds redaction), so we keep the rendering + * branch alive. See state.ts «pairing_verifying» comment. */} + {state.kind === 'qr_verifying' || state.kind === 'pairing_verifying' ? ( +
+
+ + + {state.kind === 'qr_verifying' + ? t('status.qr-verifying') + : t('status.pairing-verifying')} + + {/* Recovery refresh — if the bridge stalls between + * scan-accept and the success/failure follow-up, refresh + * fires `list-logins` to recalibrate. */} + +
+
+ ) : null} + + {state.kind === 'logging_out' ? ( +
+
+ + + {t('status.logging-out')} + + +
+
+ ) : null} + + {state.kind === 'connected' ? ( +
+ {state.loginId ? ( + + + {state.handle + ? t('status.connected-as', { handle: state.handle }) + : t('status.connected')} + + ) : ( +
+ + + {state.handle + ? t('status.connected-as', { handle: state.handle }) + : t('status.connected')} + + +
+ )} +
+ + setAboutOpen(true)} /> +
+
+ ) : null} + + {aboutOpen ? setAboutOpen(false)} /> : null} + {warningOpen ? setWarningOpen(false)} /> : null} + +
+
+ {transcript.length === 0 ? ( +
{/* placeholder kept blank intentionally */}
+ ) : ( + transcript + .slice() + .reverse() + .map((line) => ( +
+ {formatTime(line.ts)} + + {line.kind === 'from-bot' ? renderBody(line.text) : line.text} + +
+ )) + )} +
+
+
+ ); +} diff --git a/apps/widget-whatsapp/src/bootstrap.ts b/apps/widget-whatsapp/src/bootstrap.ts new file mode 100644 index 00000000..74ec1293 --- /dev/null +++ b/apps/widget-whatsapp/src/bootstrap.ts @@ -0,0 +1,67 @@ +// Parse the URL params the 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. + +export type WidgetBootstrap = { + widgetId: string; + parentUrl: string; + parentOrigin: string; + roomId: string; + 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; + theme: 'light' | 'dark'; + clientLanguage: string; +}; + +export type BootstrapResult = + | { ok: true; bootstrap: WidgetBootstrap } + | { ok: false; missing: string[] }; + +const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const; + +export const readBootstrap = (search: string): BootstrapResult => { + const params = new URLSearchParams(search); + const get = (k: string) => params.get(k) ?? ''; + + const missing = REQUIRED.filter((k) => !params.get(k)); + if (missing.length > 0) return { ok: false, missing: [...missing] }; + + // Origin is what the widget validates against on incoming postMessage — + // see widget-api.ts. Falling back to '*' would defeat the security + // boundary, so a malformed parentUrl bails out as a missing-param error. + let parentOrigin: string; + try { + parentOrigin = new URL(get('parentUrl')).origin; + } catch { + return { ok: false, missing: ['parentUrl'] }; + } + + const themeRaw = get('theme'); + const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light'; + + return { + ok: true, + bootstrap: { + widgetId: get('widgetId'), + parentUrl: get('parentUrl'), + parentOrigin, + roomId: get('roomId'), + userId: get('userId'), + botId: get('botId'), + botMxid: get('botMxid'), + commandPrefix: get('commandPrefix'), + 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 new file mode 100644 index 00000000..25270f55 --- /dev/null +++ b/apps/widget-whatsapp/src/bridge-protocol/dialects/bridgev2_v0264.ts @@ -0,0 +1,849 @@ +// 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 new file mode 100644 index 00000000..efe4ecb6 --- /dev/null +++ b/apps/widget-whatsapp/src/bridge-protocol/parser.ts @@ -0,0 +1,17 @@ +// 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 new file mode 100644 index 00000000..29c9295e --- /dev/null +++ b/apps/widget-whatsapp/src/bridge-protocol/types.ts @@ -0,0 +1,122 @@ +// 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/i18n/en.ts b/apps/widget-whatsapp/src/i18n/en.ts new file mode 100644 index 00000000..14659f1b --- /dev/null +++ b/apps/widget-whatsapp/src/i18n/en.ts @@ -0,0 +1,116 @@ +// English fallback. Mirror the RU key set; `Record` +// enforces 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…', + '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.warning.name': 'Read before linking', + 'card.warning.desc': 'Important information about risks — tap to open', + 'warning.title': 'Read 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.body-2': + 'WhatsApp bans are regular and unpredictable — Meta does not publish criteria. For some users the bridge works for years without issue; for others the account is banned within hours of linking.', + 'warning.tos-label': 'WhatsApp terms of service:', + 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', + 'warning.close': 'Got it', + 'warning.aria-close': 'Close warning', + 'warning.about-callout': + '⚠ Before linking WhatsApp, read the “Read before linking” card on the bot’s main screen.', + 'card.about.name': 'How the WhatsApp bot works', + 'card.about.desc': 'Sign-in, safety, and source code', + '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.', + 'about.body-2': + 'Sign-in requires the WhatsApp mobile app on a phone with an active account. You can either scan a QR code via Settings → Linked devices → Link a device, or enter an 8-character pairing code via Settings → Linked devices → Link with phone number.', + 'about.body-3': + 'The connection runs through the open-source mautrix-whatsapp bridge. It creates a WhatsApp session on the Vojo server and uses it to connect WhatsApp with your Vojo account: receive messages from WhatsApp and send your replies back. Your WhatsApp account keeps working on your phone as usual — the bridge connects in parallel as another linked device.', + '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.', + 'about.close': 'Close', + 'about.aria-close': 'Close “About this bot”', + 'auth-card.phone.title': 'Sign in with a pairing code', + 'auth-card.phone.label': 'Phone number', + 'auth-card.phone.placeholder': '+15551234567', + '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.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.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.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.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}.', +}; diff --git a/apps/widget-whatsapp/src/i18n/index.ts b/apps/widget-whatsapp/src/i18n/index.ts new file mode 100644 index 00000000..1af7a208 --- /dev/null +++ b/apps/widget-whatsapp/src/i18n/index.ts @@ -0,0 +1,30 @@ +// 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'; + +const interpolate = (s: string, vars?: Record): string => { + if (!vars) return s; + return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); +}; + +const pickDict = (clientLanguage: string | undefined): Record => { + const lang = ( + clientLanguage || + (typeof navigator !== 'undefined' ? navigator.language : '') || + 'ru' + ).toLowerCase(); + return lang.startsWith('en') ? EN : RU; +}; + +export type T = (key: StringKey, vars?: Record) => string; + +export const createT = (clientLanguage?: string): T => { + const dict = pickDict(clientLanguage); + return (key, vars) => interpolate(dict[key], vars); +}; + +export type { StringKey }; diff --git a/apps/widget-whatsapp/src/i18n/ru.ts b/apps/widget-whatsapp/src/i18n/ru.ts new file mode 100644 index 00000000..c8ef490a --- /dev/null +++ b/apps/widget-whatsapp/src/i18n/ru.ts @@ -0,0 +1,211 @@ +// Russian primary copy. To add a string: +// 1. add the key + RU value here (this file is the canonical key list — `en.ts` +// and the `StringKey` type derive from it), +// 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.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 --------------------------------------------------- + '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': 'Проверяю…', + // --- Warning card (WhatsApp-specific) --------------------------------- + // Карточка-предупреждение ставится ТОЛЬКО для WhatsApp в каталоге + // Vojo, потому что у WhatsApp ToS прямо запрещает подключение + // через сторонние клиенты и Meta это активно энфорсит. Сравнения с + // Telegram/Discord В ИНТЕРФЕЙСЕ намеренно НЕТ: Telegram user ToS + // (telegram.org/tos) такого ограничения вообще не упоминает, а + // Discord — отдельный кейс. Делать сравнение в копии = вводить + // юзера в заблуждение, поэтому warning-модалка говорит ТОЛЬКО про + // WhatsApp/Meta и ссылается ТОЛЬКО на Meta ToS. + // + // ToS reference: https://www.whatsapp.com/legal/terms-of-service + // секция «Harm To WhatsApp Or Our Users» запрещает «software or + // APIs that function substantially the same as our Services» и + // «accounts for our Services through unauthorized or automated + // means». + // Triangle glyph lives in the icon slot to the LEFT — don't repeat + // it in the title text or it doubles up («⚠ ⚠ Прочтите...»). + 'card.warning.name': 'Прочтите перед подключением', + // Two-line desc: hint + explicit «click here to read» so the user + // doesn't stare at the card wondering if it's interactive (the + // amber tone helps signal it's special, but doesn't tell you it's + // a button). + 'card.warning.desc': 'Важная информация о рисках — нажмите, чтобы открыть', + 'warning.title': 'Важно знать до подключения WhatsApp', + // Два информационных параграфа: что технически делает мост и + // почему это под риском, и насколько риск реален. Сравнение + // с другими мессенджерами оставлено НЕЯВНЫМ («в отличие от других + // мессенджеров») — без явного перечисления TG/Discord, потому что + // у Telegram user ToS (telegram.org/tos) запрета на сторонние + // клиенты нет вообще, у Discord ToS тоже нет (запрет self-bot'ов + // живёт у них в developer policies, не в ToS proper). Прямой + // формальный запрет в ToS есть ТОЛЬКО у WhatsApp; общая фраза + // «в отличие от других мессенджеров» подчёркивает уникальность + // WhatsApp без неточностей в адрес конкретных сервисов. + 'warning.body-1': + 'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.', + 'warning.body-2': + 'Блокировки со стороны WhatsApp регулярны и непредсказуемы — точные критерии Meta не публикует. У одних мост работает годами без проблем, у других аккаунт блокируется в первые часы после привязки.', + // Источник про запрет в 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', + 'warning.close': 'Понятно', + 'warning.aria-close': 'Закрыть предупреждение', + // Короткий callout в About-модале — пойнтер на отдельную карточку + // с предупреждением. Объяснение «почему» живёт в самой модалке + // warning'а; здесь — только указание куда смотреть, чтобы About + // не дублировал warning по сути. + 'warning.about-callout': + '⚠ Перед подключением WhatsApp прочтите карточку «Прочтите перед подключением» на главном экране бота.', + // --- About panel ------------------------------------------------------- + 'card.about.name': 'Как работает WhatsApp-бот', + 'card.about.desc': 'Вход, безопасность и исходный код', + 'about.title': 'О боте WhatsApp', + 'about.body-1': + 'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.', + 'about.body-2': + 'Для входа нужно мобильное приложение WhatsApp на телефоне с активным аккаунтом. Можно либо отсканировать QR-код через «Настройки → Связанные устройства → Привязать устройство», либо ввести 8-символьный код через «Настройки → Связанные устройства → Привязать с помощью номера телефона».', + 'about.body-3': + 'Подключение работает через open-source мост mautrix-whatsapp. Он создаёт WhatsApp-сессию на сервере Vojo и использует её для связи WhatsApp с вашим аккаунтом Vojo: получает сообщения из WhatsApp и отправляет ваши ответы обратно. WhatsApp-аккаунт продолжит работать на телефоне как обычно — мост подключается параллельно, как ещё одно связанное устройство.', + 'about.github-label': 'Исходный код моста открыт на GitHub:', + 'about.github-url': 'https://github.com/mautrix/whatsapp', + 'about.body-4': + 'Отозвать доступ можно в любой момент — кнопкой «Выйти из WhatsApp» здесь, либо в самом WhatsApp через «Настройки → Связанные устройства → Выйти со всех устройств».', + 'about.close': 'Закрыть', + 'about.aria-close': 'Закрыть «О боте»', + // --- Phone form (pairing-code flow) ------------------------------------ + 'auth-card.phone.title': 'Вход по коду из приложения', + 'auth-card.phone.label': 'Номер телефона', + 'auth-card.phone.placeholder': '+79991234567', + // Подсказка, объясняющая что произойдёт после сабмита: мост создаст + // 8-символьный код, который надо ввести в WhatsApp app. Пользователь + // должен понимать, что код не SMS-OTP, а pairing-token. + 'auth-card.phone.hint': + 'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.', + 'auth-card.phone.submit': 'Получить код', + 'auth-card.phone.cooldown': 'Повтор через {seconds} сек', + // --- Pairing-code form ------------------------------------------------- + 'auth-card.pairing-code.title': 'Введите этот код в WhatsApp', + 'auth-card.pairing-code.hint': + 'Откройте WhatsApp на телефоне и введите этот код в форме «Связанные устройства → Привязать с помощью номера телефона».', + 'auth-card.pairing-code.preparing': 'Готовим код…', + '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.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 на каждой ротации), + // отсчёт показывает оставшееся окно ВСЕГО входа. + '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 разорвал сессию — нужен повторный вход.', + // --- Bootstrap failure ------------------------------------------------- + 'bootstrap.failed': 'Widget не запустился', + 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', + 'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.', +} as const; + +export type StringKey = keyof typeof RU; diff --git a/apps/widget-whatsapp/src/main.tsx b/apps/widget-whatsapp/src/main.tsx new file mode 100644 index 00000000..5fc5d57f --- /dev/null +++ b/apps/widget-whatsapp/src/main.tsx @@ -0,0 +1,79 @@ +import { render } from 'preact'; +import { readBootstrap } from './bootstrap'; +import { App } from './App'; +import { createT } from './i18n'; +import { WidgetApi, buildCapabilities } from './widget-api'; +import './styles.css'; + +// Input-mode detector. Capacitor's Android Chromium WebView reports +// `(hover: hover)` and `(any-pointer: fine)` as TRUE on a pure-touch +// device — verified via on-device console.log of `matchMedia(...).matches`. +// That makes media-query gating of `:hover` styles unreliable: the rule +// fires on touch and then sticks (Chromium WebView synthesises `:hover` on +// the focused element after a tap and never clears it until the next +// interaction elsewhere). The visible symptom is a card that «greys out +// after tap and only un-greys when you tap a different button». +// +// Real input is determined from the actual `pointerdown.pointerType` at +// runtime. The first pointerdown after load is authoritative; CSS gates +// hover styling via `:root[data-input="mouse"]`. The initial guess based +// on `(any-pointer: coarse)` covers the pre-first-pointerdown frame so +// the first paint on a touch device doesn't briefly show the mouse-mode +// hover affordances if the user immediately taps a card. +const setInputMode = (mode: 'touch' | 'mouse'): void => { + document.documentElement.dataset.input = mode; +}; +setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse'); +window.addEventListener( + 'pointerdown', + (event) => { + setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch'); + }, + { passive: true, capture: true } +); + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app root element missing — index.html out of sync'); +} + +const result = readBootstrap(window.location.search); + +if (!result.ok) { + // Either someone opened the widget URL directly (no host params), or a + // host bug failed to provide them. Either way render a self-contained + // diagnostic instead of going silent. Bootstrap failed before we could + // read clientLanguage from the URL, so let createT fall back to + // navigator.language. + const t = createT(); + render( +
+
+ {t('bootstrap.failed')} + {t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '} + {t('bootstrap.embedded-only', { route: '/bots/whatsapp' })} +
+
, + root + ); +} else { + // Apply initial theme synchronously so the first paint isn't flashed + // 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)); + render(, root); +} diff --git a/apps/widget-whatsapp/src/state.ts b/apps/widget-whatsapp/src/state.ts new file mode 100644 index 00000000..d4c99c2e --- /dev/null +++ b/apps/widget-whatsapp/src/state.ts @@ -0,0 +1,1026 @@ +// 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 new file mode 100644 index 00000000..3ae9c48c --- /dev/null +++ b/apps/widget-whatsapp/src/styles.css @@ -0,0 +1,1103 @@ +/* Dawn palette — must stay in sync with + * docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx + * (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot + * which is itself a Dawn surface; the iframe inherits the same visual + * canon to feel like a continuation of the host. */ + +:root { + --bg: #181a20; + --bg2: #0d0e11; + --surface: #21232b; + --surface2: #2a2d36; + --divider: rgba(255, 255, 255, 0.06); + --hairline: rgba(255, 255, 255, 0.08); + --text: #e6e6e9; + --muted: rgba(230, 230, 233, 0.55); + --faint: rgba(230, 230, 233, 0.32); + --fleet: #9580ff; + --fleet-soft: #a59cff; + --green: #7dd3a8; + --amber: #d4b88a; + --rose: #c08e7b; + --section-pad-x: 40px; +} + +[data-theme='light'] { + /* Light theme is intentionally a thin remap. Vojo is dark-default; the + * theme param exists so we don't fight an explicit user/host setting, + * not because we expect daily light-mode use. */ + --bg: #f5f5f7; + --bg2: #ffffff; + --surface: #f0f0f2; + --surface2: #e8e8ec; + --divider: rgba(0, 0, 0, 0.08); + --hairline: rgba(0, 0, 0, 0.1); + --text: #1a1a1d; + --muted: rgba(26, 26, 29, 0.62); + --faint: rgba(26, 26, 29, 0.4); +} + +@media (max-width: 600px) { + :root { + --section-pad-x: 20px; + } +} + +* { + box-sizing: border-box; + /* Kills the translucent grey overlay iOS/Android WebViews paint on top + * of any tapped element. On the wide refresh card this overlay was + * read as «button stuck on grey» — the underlying state was correct, + * the WebView's tap-highlight was not. Web browsers ignore this. */ + -webkit-tap-highlight-color: transparent; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100%; + max-width: 960px; + margin: 0 auto; +} + +/* The hero (avatar + name + handle + description + three-dots menu) is + * OWNED BY THE HOST, not the widget — see src/app/features/bots/BotShell.tsx. + * Removing the widget-side hero collapses the duplicate header that used to + * sit between the host's BotShellHero (which the user actually sees) and + * the iframe content. The widget body now starts with the active-state + * section directly. */ + +/* ── Section ──────────────────────────────────────────────────────── */ + +.section { + padding: 24px var(--section-pad-x) 20px; +} + +.section + .section { + 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. */ +.section-status { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 13px; + line-height: 20px; + color: var(--muted); + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + padding: 8px 14px; + margin: 0 0 14px; + user-select: none; + white-space: nowrap; +} + +.section-status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); + flex-shrink: 0; +} + +.section-status.connected { + color: var(--green); +} +.section-status.connected .dot { + background: var(--green); + box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16); +} + +.section-status.disconnected { + color: var(--rose); +} +.section-status.disconnected .dot { + background: var(--rose); +} + +.section-status.checking { + color: var(--amber); +} +.section-status.checking .dot { + background: var(--amber); +} + +/* Wraps the section-status pill + a labeled refresh action when the + * state has no other affordance (unknown / logging_out / connected + * without loginId). Without this row, the user can stare at a + * «Проверка статуса…» pill forever if the first list-logins reply + * dropped on the wire. */ +.section-recovery-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.section-recovery-row > .section-status { + margin-bottom: 0; +} + +.recovery-action { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + padding: 8px 14px; + font: inherit; + font-size: 13px; + line-height: 20px; + color: var(--muted); + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +:root[data-input='mouse'] .recovery-action:hover:not(:disabled) { + background: var(--surface); + color: var(--text); + border-color: var(--hairline); +} +.recovery-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.recovery-action svg { + width: 16px; + height: 16px; +} + +/* ── Command card (action card with name + desc + chevron) ──────── */ + +.command-card { + /* The widget runs in an iframe, so it does NOT inherit the host's + * `button { -webkit-appearance: button }` rule (src/index.css:112). The + * browser default for