feat(bots-whatsapp): land Preact widget for mautrix-whatsapp QR + pairing-code login, Meta-ToS warning card, and cross-iframe external-link relay
This commit is contained in:
parent
bd6bcd7d1c
commit
4559a89a31
27 changed files with 7870 additions and 26 deletions
|
|
@ -445,6 +445,27 @@ export function App({ bootstrap, api }: Props) {
|
|||
document.documentElement.dataset.theme = theme;
|
||||
}, [theme]);
|
||||
|
||||
// Capture-phase click interceptor for `<a target="_blank">` —
|
||||
// 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<TranscriptLine, 'id' | 'ts'>) => {
|
||||
setTranscript((prev) => {
|
||||
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
|
||||
|
|
|
|||
|
|
@ -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 `<a target="_blank">` clicks — the WebView
|
||||
// doesn't have a multi-window concept, and the host's global
|
||||
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||
// inside the host document, not inside the iframe (cross-origin
|
||||
// events don't bubble across the frame boundary). The widget posts
|
||||
// this message instead; the host calls `openExternalUrl(url)` which
|
||||
// routes to `Browser.open` on native and `window.open` on web.
|
||||
public openExternalUrl(url: string): void {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: 'io.vojo.bot-widget',
|
||||
action: 'open-external-url',
|
||||
data: { url },
|
||||
},
|
||||
this.bootstrap.parentOrigin
|
||||
);
|
||||
}
|
||||
|
||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||
// Legacy mautrix-discord routes management-room commands through the
|
||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||
|
|
|
|||
|
|
@ -856,6 +856,27 @@ export function App({ bootstrap, api }: Props) {
|
|||
document.documentElement.dataset.theme = theme;
|
||||
}, [theme]);
|
||||
|
||||
// Capture-phase click interceptor for `<a target="_blank">` —
|
||||
// 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<TranscriptLine, 'id' | 'ts'>) => {
|
||||
setTranscript((prev) => {
|
||||
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
|
||||
|
|
|
|||
|
|
@ -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 `<a target="_blank">` clicks — the WebView
|
||||
// doesn't have a multi-window concept, and the host's global
|
||||
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||
// inside the host document, not inside the iframe (cross-origin
|
||||
// events don't bubble across the frame boundary). The widget posts
|
||||
// this message instead; the host calls `openExternalUrl(url)` which
|
||||
// routes to `Browser.open` on native and `window.open` on web.
|
||||
public openExternalUrl(url: string): void {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: 'io.vojo.bot-widget',
|
||||
action: 'open-external-url',
|
||||
data: { url },
|
||||
},
|
||||
this.bootstrap.parentOrigin
|
||||
);
|
||||
}
|
||||
|
||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
|
||||
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
||||
// the management room and any other room the bot may have been moved to.
|
||||
|
|
|
|||
137
apps/widget-whatsapp/README.md
Normal file
137
apps/widget-whatsapp/README.md
Normal file
|
|
@ -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 (`<ref>,<noise>,<identity>,<adv>`,
|
||||
four base64 fields). The widget renders it as a QR matrix client-side.
|
||||
Whatsmeow `qrIntervals = [60s, 20s, 20s, 20s, 20s, 20s]` — first QR
|
||||
lasts 60 seconds, then five rotations of 20 seconds each. Total active
|
||||
window: 2 minutes 40 seconds. Each rotation arrives as an `m.replace`
|
||||
edit of the original event; the state machine matches on the original
|
||||
id and repaints the matrix.
|
||||
|
||||
2. **Pairing code** (`!wa login phone`) — alternative for users whose
|
||||
camera doesn't work or who prefer typing. The user enters a phone
|
||||
number; the bridge replies with two notices:
|
||||
- `Input the pairing code in the WhatsApp mobile app to log in`
|
||||
- The 8-character code itself (`XXXX-XXXX`, custom base32 alphabet).
|
||||
The widget renders the code prominently and the user enters it in
|
||||
WhatsApp → Settings → Linked devices → Link with phone number.
|
||||
|
||||
There is **no 2FA cloud-password step** — multidevice handshake is
|
||||
single-factor. The state machine has no `awaiting_password` arm.
|
||||
|
||||
## Capability contract
|
||||
|
||||
The widget requests EXACTLY this set (matches the host's
|
||||
`BotWidgetDriver.getBotWidgetCapabilities`):
|
||||
|
||||
```
|
||||
org.matrix.msc2762.timeline:<roomId>
|
||||
org.matrix.msc2762.send.event:m.room.message#m.text
|
||||
org.matrix.msc2762.receive.event:m.room.message#m.text
|
||||
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
||||
org.matrix.msc2762.receive.event:m.room.message#m.image
|
||||
org.matrix.msc2762.receive.event:m.room.redaction
|
||||
org.matrix.msc2762.receive.state_event:m.room.member
|
||||
```
|
||||
|
||||
Anything else is silently dropped by the host. The capability set is
|
||||
identical to the Telegram widget's M13 expansion — the host driver
|
||||
already supports `m.image` + `m.room.redaction`.
|
||||
|
||||
## Local development
|
||||
|
||||
```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: <https://github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go>
|
||||
- mautrix-whatsapp connector (post-login session events):
|
||||
<https://github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go>
|
||||
- whatsmeow QR format: <https://github.com/tulir/whatsmeow/blob/main/pair.go> (`makeQRData`)
|
||||
- whatsmeow pairing-code: <https://github.com/tulir/whatsmeow/blob/main/pair-code.go> (`PairPhone`)
|
||||
- bridgev2 commands layer (shared with mautrix-telegram):
|
||||
<https://github.com/mautrix/go/blob/main/bridgev2/commands/login.go>
|
||||
|
||||
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.
|
||||
12
apps/widget-whatsapp/index.html
Normal file
12
apps/widget-whatsapp/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>WhatsApp bridge — Vojo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1999
apps/widget-whatsapp/package-lock.json
generated
Normal file
1999
apps/widget-whatsapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
apps/widget-whatsapp/package.json
Normal file
21
apps/widget-whatsapp/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
1487
apps/widget-whatsapp/src/App.tsx
Normal file
1487
apps/widget-whatsapp/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
67
apps/widget-whatsapp/src/bootstrap.ts
Normal file
67
apps/widget-whatsapp/src/bootstrap.ts
Normal file
|
|
@ -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 `<commandPrefix> ` to every
|
||||
* outbound command and form-field value (bridgev2/queue.go:118 strips
|
||||
* exactly `prefix+" "`). For mautrix-whatsapp the operator must set
|
||||
* `commandPrefix: "!wa"` in /config.json — connector.go ships
|
||||
* `DefaultCommandPrefix: "!wa"`. */
|
||||
commandPrefix: string;
|
||||
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'),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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 <Name>")
|
||||
// - list-logins reply: user.go:185-190 ("\n* `<id>` (<Name>) - `<state>`")
|
||||
// - logout reply: commands/login.go:591 ("Logged out")
|
||||
// - cancel replies: commands/processor.go:198/200
|
||||
// ("Login cancelled.", "No ongoing command.")
|
||||
// - login_in_progress: commands/login.go:83
|
||||
// ("You already have an ongoing login...")
|
||||
// - max_logins: commands/login.go:74-79
|
||||
// ("You have reached the maximum number of logins (N)")
|
||||
// - login_not_found: commands/login.go:587/68 ("Login `id` not found")
|
||||
// - flow_required / invalid: commands/login.go:107/98
|
||||
// - unknown_command: commands/processor.go:163
|
||||
// - generic error traps: commands/login.go (Failed to ..., Login failed: ...)
|
||||
// - login_failed display-and-wait branch:
|
||||
// commands/login.go:366 ("Login failed: %v")
|
||||
// - QR rendering as m.image: bridgev2/commands/login.go sendQR (`Body: qr`)
|
||||
//
|
||||
// CONNECTOR mautrix-whatsapp:
|
||||
// github.com/mautrix/whatsapp/blob/main/pkg/connector/login.go
|
||||
// - Phone field name: "Phone number" + description
|
||||
// "Your WhatsApp phone number in international format"
|
||||
// - QR Instructions: "Scan the QR code with the WhatsApp mobile app to log in"
|
||||
// - Code Instructions: "Input the pairing code in the WhatsApp mobile app to log in"
|
||||
// - Login complete Instructions: fmt.Sprintf("Successfully logged in as %s", ul.RemoteName)
|
||||
// where RemoteName = "+<phone-number>"
|
||||
// - Connector errors (RespError values, surface via login_failed trap):
|
||||
// CLIENT_OUTDATED: "Got client outdated error while waiting for QRs..."
|
||||
// MULTIDEVICE_NOT_ENABLED: "Please enable WhatsApp web multidevice..."
|
||||
// LOGIN_TIMEOUT: "Entering code or scanning QR timed out. Please try again."
|
||||
// UNEXPECTED_EVENT: "Unexpected event while waiting for login"
|
||||
// PHONE_NUMBER_TOO_SHORT: "Phone number too short"
|
||||
// PHONE_NUMBER_NOT_INTERNATIONAL: "Phone number must be in international format"
|
||||
// RATE_LIMITED: "Rate limited by WhatsApp"
|
||||
// PAIR_ERROR: "<go-error from PairError event>"
|
||||
//
|
||||
// github.com/mautrix/whatsapp/blob/main/pkg/connector/handlewhatsapp.go
|
||||
// - external logout: "You were logged out from another device. Relogin to..."
|
||||
// "Your phone was logged out from WhatsApp. Relogin to..."
|
||||
// "You were logged out for an unknown reason. Relogin to..."
|
||||
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
|
||||
// - connection: "Reconnecting to WhatsApp...", "Disconnected from WhatsApp. Trying to reconnect.",
|
||||
// "Your phone hasn't been seen in over 12 days...",
|
||||
// "The WhatsApp web servers are not responding...",
|
||||
// "Connecting to the WhatsApp web servers failed.",
|
||||
// "Stream replaced: the bridge was started in another location."
|
||||
//
|
||||
// QR PAYLOAD (whatsmeow):
|
||||
// github.com/tulir/whatsmeow/blob/main/pair.go ::makeQRData
|
||||
// strings.Join([]string{ref, noise, identity, adv}, ",")
|
||||
// → 4 base64-ish fields separated by literal commas. NOT a URL.
|
||||
//
|
||||
// PAIRING CODE FORMAT (whatsmeow):
|
||||
// github.com/tulir/whatsmeow/blob/main/pair-code.go ::PairPhone
|
||||
// 8 chars from base32 alphabet "123456789ABCDEFGHJKLMNPQRSTVWXYZ"
|
||||
// formatted as XXXX-XXXX (4 chars + "-" + 4 chars).
|
||||
|
||||
import type { LoginEvent, ListedLogin, ParsableEvent, ExternalLogoutReason } from '../types';
|
||||
|
||||
// --- Regex table — shared bridgev2 wording -------------------------------
|
||||
|
||||
// list-logins, empty: bridgev2/commands/login.go → `You're not logged in`.
|
||||
// NO trailing period. Same as Telegram dialect — kept anchored just in case
|
||||
// a future bridgev2 patch drifts.
|
||||
const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||
|
||||
// list-logins, non-empty: bridgev2/user.go ships a leading `\n` due to a
|
||||
// `make([]string, N) + append` bug. Each row is
|
||||
// `* \`<id>\` (<RemoteName>) - \`<state>\``.
|
||||
//
|
||||
// For WhatsApp:
|
||||
// <id> = JID-derived login id (digits, possibly digits.0)
|
||||
// <RemoteName> = "+<phone-number>" (e.g. "+12345678901")
|
||||
// <state> = state string ("CONNECTED" etc)
|
||||
//
|
||||
// Greedy `(.+)` capture for name backtracks to the LAST `)` before
|
||||
// ` - `<state>`` — paranoid against future RemoteName drift even though
|
||||
// WhatsApp's RemoteName is currently always `+<digits>`.
|
||||
const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
|
||||
|
||||
// Phone prompt — bridgev2/commands/login.go composes
|
||||
// `Please enter your <field.Name>\n<field.Description>`. Connector field
|
||||
// is { Name: "Phone number", Description: "Your WhatsApp phone number in
|
||||
// international format" } — but we anchor on the prefix only so that an
|
||||
// upstream tweak to the description doesn't break detection.
|
||||
const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
|
||||
|
||||
// Login success — bridgev2 renders Instructions as a plain reply. WhatsApp
|
||||
// connector's success Instructions: `Successfully logged in as +<phone>`.
|
||||
// Distinct from Telegram's `Successfully logged in as @handle (\`id\`)` —
|
||||
// no parens, no numeric ID. Capture the handle (which IS the phone).
|
||||
//
|
||||
// Tolerate optional trailing period (bridgev2 doesn't add one but a future
|
||||
// patch might) and optional surrounding whitespace.
|
||||
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(\+?[\w.+-]+)\.?$/i;
|
||||
|
||||
// Logout — bridgev2/commands/login.go → `Logged out` (no period).
|
||||
const LOGOUT_OK_RE = /^logged out\.?$/i;
|
||||
|
||||
// Cancel — bridgev2/commands/processor.go ::CommandCancel emits
|
||||
// `Reply("%s cancelled.", action)` where `action` is the stored
|
||||
// CommandState.Action. Today every WA login path uses Action="Login",
|
||||
// so the rendered string is "Login cancelled." — but matching that
|
||||
// literal would fail if a future bridgev2 ever introduces another
|
||||
// action (e.g. "Logout"/"Relogin") that triggers this reply path.
|
||||
// The relaxed pattern matches «<word> cancelled.» so the cancel-ok
|
||||
// flow stays robust to the upstream wording shape, not its action
|
||||
// name. Source: https://raw.githubusercontent.com/mautrix/go/main/bridgev2/commands/processor.go
|
||||
const CANCEL_OK_RE = /^\S+ cancelled\.?$/i;
|
||||
const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
|
||||
|
||||
// Login already in progress — bridgev2/commands/login.go.
|
||||
const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
|
||||
|
||||
// Max logins — bridgev2/commands/login.go. Captures the limit.
|
||||
const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
|
||||
|
||||
// Login id not found — bridgev2/commands/login.go (logout / relogin).
|
||||
const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
|
||||
|
||||
// Flow selector errors — bridgev2/commands/login.go. WhatsApp returns
|
||||
// `flow_required` for bare `!wa login` because GetLoginFlows returns 2
|
||||
// flows. The widget always sends `login qr` / `login phone`, so this
|
||||
// trap exists as defence-in-depth (e.g. the user typed `!wa login` in
|
||||
// chat-fallback).
|
||||
const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
|
||||
const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
|
||||
|
||||
// Unknown command — bridgev2/commands/processor.go.
|
||||
const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
|
||||
|
||||
// Generic error traps. Each anchors on a distinct prefix.
|
||||
const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
|
||||
const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
|
||||
const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
|
||||
const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
|
||||
// `Login failed: %v` from doLoginDisplayAndWait Wait error path.
|
||||
// All connector-side WhatsApp login errors funnel through here.
|
||||
const LOGIN_FAILED_RE = /^login failed:\s*(.*)$/i;
|
||||
|
||||
// --- Regex table — connector-specific wording ----------------------------
|
||||
|
||||
// QR Instructions — connector login.go ::makeQRStep:
|
||||
// `Scan the QR code with the WhatsApp mobile app to log in`.
|
||||
//
|
||||
// The widget doesn't strictly need to recognise this on its own (the
|
||||
// m.image with the QR data is the operative signal for state transition),
|
||||
// but emitting an `unknown` for it would litter the transcript with diag
|
||||
// lines for every QR rotation. We swallow it as a discrete event so the
|
||||
// state machine can ignore it without leaving it in transcript.
|
||||
const QR_INSTRUCTIONS_RE = /^scan the qr code with the whatsapp mobile app\b/i;
|
||||
|
||||
// Pairing-code Instructions — connector login.go ::SubmitUserInput:
|
||||
// `Input the pairing code in the WhatsApp mobile app to log in`. First
|
||||
// of TWO bot replies after a phone-number submit on `!wa login phone`;
|
||||
// the actual code lands in the next reply.
|
||||
const PAIRING_CODE_INSTRUCTIONS_RE = /^input the pairing code in the whatsapp mobile app\b/i;
|
||||
|
||||
// Pairing code body — `XXXX-XXXX` from whatsmeow's PairPhone, rendered
|
||||
// via bridgev2's ReplyAdvanced as `<code>XXXX-XXXX</code>` HTML. After
|
||||
// `format.RenderMarkdown` (mautrix/go) routes through `HTMLToContent` →
|
||||
// `SafeMarkdownCode` (format/markdown.go), the body field is ALWAYS
|
||||
// the markdown-source `` `XXXX-XXXX` `` (backticks wrapped around the
|
||||
// code). The earlier comment claimed «either plain or backticked» —
|
||||
// in practice bridgev2 always emits the backticked form; the regex's
|
||||
// `\`?` keeps the plain-form path tolerant for future framework
|
||||
// changes that strip the wrapping.
|
||||
// Character class follows whatsmeow's custom base32 alphabet
|
||||
// `123456789ABCDEFGHJKLMNPQRSTVWXYZ` exactly: digits 1-9, uppercase
|
||||
// letters minus I, O, U.
|
||||
const PAIRING_CODE_RE = /^\s*`?([1-9A-HJ-NP-TV-Z]{4}-[1-9A-HJ-NP-TV-Z]{4})`?\s*$/;
|
||||
|
||||
// External-logout reasons — connector handlewhatsapp.go. Each anchors on
|
||||
// the verbatim wording, captures nothing (the kind itself encodes the
|
||||
// reason). Matching three classes:
|
||||
// 1. Logged out from another device (multidevice unlink elsewhere).
|
||||
// 2. Phone was logged out from WhatsApp (user logged out the WA app
|
||||
// itself, which kills every linked device).
|
||||
// 3. Logged out for an unknown reason (everything else, including
|
||||
// "You're not logged into WhatsApp" idle-bridge case).
|
||||
const LOGGED_OUT_FROM_ANOTHER_DEVICE_RE = /^you were logged out from another device\b/i;
|
||||
const PHONE_LOGGED_OUT_RE = /^your phone was logged out from whatsapp\b/i;
|
||||
const LOGGED_OUT_UNKNOWN_RE = /^you were logged out for an unknown reason\b/i;
|
||||
// "You're not logged into WhatsApp. Relogin to continue using the bridge."
|
||||
// — emitted by the connector at startup if no session exists OR after a
|
||||
// re-init that found no session. Treated as `external_logout{unknown}`
|
||||
// because the visible result (need to re-login) is identical.
|
||||
const NOT_LOGGED_INTO_WHATSAPP_RE = /^you'?re not logged into whatsapp\b/i;
|
||||
|
||||
// Connection warnings — connector handlewhatsapp.go. None of these mean
|
||||
// the user has to do anything; surface in transcript only.
|
||||
// `Connect failure: 405 client outdated. Bridge must be updated.` IS
|
||||
// effectively a hard wall (no flow can succeed until the bridge image
|
||||
// is upgraded), but surfacing it as a connection_warning rather than
|
||||
// an `unknown` keeps the transcript readable; the user will see it
|
||||
// alongside the eventual login_failed.
|
||||
// `You're not connected to WhatsApp` is the human-readable label of
|
||||
// the WANotConnected BridgeState code — it doesn't typically reach
|
||||
// the management room as an m.notice, but match it just in case a
|
||||
// future bridgev2 patch wires it into one.
|
||||
const CONNECTION_WARNING_RES: RegExp[] = [
|
||||
/^reconnecting to whatsapp/i,
|
||||
/^disconnected from whatsapp\. trying to reconnect/i,
|
||||
/^your phone hasn'?t been seen in over\b/i,
|
||||
/^the whatsapp web servers are not responding\b/i,
|
||||
/^connecting to the whatsapp web servers failed/i,
|
||||
/^stream replaced: the bridge was started in another location/i,
|
||||
/^connect failure: \d+\b/i,
|
||||
/^you'?re not connected to whatsapp\b/i,
|
||||
];
|
||||
|
||||
// --- Body parser ---------------------------------------------------------
|
||||
|
||||
const trimReplyBody = (raw: string): string => raw.trim();
|
||||
|
||||
const parseLoginList = (body: string): ListedLogin[] => {
|
||||
const logins: ListedLogin[] = [];
|
||||
// matchAll requires the global flag — rebuild the RegExp each call so
|
||||
// the shared instance's lastIndex doesn't bleed between callers.
|
||||
const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
|
||||
for (const match of body.matchAll(re)) {
|
||||
const [, id, name, state] = match;
|
||||
logins.push({ id, name, state });
|
||||
}
|
||||
return logins;
|
||||
};
|
||||
|
||||
const matchExternalLogout = (body: string): ExternalLogoutReason | undefined => {
|
||||
if (LOGGED_OUT_FROM_ANOTHER_DEVICE_RE.test(body)) return 'another_device';
|
||||
if (PHONE_LOGGED_OUT_RE.test(body)) return 'phone_logged_out';
|
||||
if (LOGGED_OUT_UNKNOWN_RE.test(body)) return 'unknown';
|
||||
if (NOT_LOGGED_INTO_WHATSAPP_RE.test(body)) return 'unknown';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isConnectionWarning = (body: string): boolean =>
|
||||
CONNECTION_WARNING_RES.some((re) => re.test(body));
|
||||
|
||||
export const parseBridgev2V0264Body = (rawBody: string): LoginEvent => {
|
||||
const body = trimReplyBody(rawBody);
|
||||
if (body.length === 0) return { kind: 'unknown' };
|
||||
|
||||
// Order: highly-specific terminal/transitional matches first, generic
|
||||
// error traps last. The login-list parser comes early because its anchor
|
||||
// (` * `<id>` `) wouldn't false-match anything else, and the alternative
|
||||
// — `not_logged_in` — covers the empty-list case explicitly.
|
||||
|
||||
// Async session events (connector-emitted) — try BEFORE shared bridgev2
|
||||
// patterns because `You're not logged into WhatsApp` wording overlaps
|
||||
// partially with `You're not logged in` (NOT_LOGGED_IN_RE) — we need
|
||||
// to win on the more specific trap.
|
||||
const externalLogout = matchExternalLogout(body);
|
||||
if (externalLogout) return { kind: 'external_logout', reason: externalLogout };
|
||||
if (isConnectionWarning(body)) return { kind: 'connection_warning', text: body };
|
||||
|
||||
if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||
|
||||
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||
if (successMatch) {
|
||||
return {
|
||||
kind: 'login_success',
|
||||
handle: successMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
|
||||
|
||||
// QR Instructions — discrete kind, swallowed by the state machine
|
||||
// (the m.image carries the operative signal). MUST come BEFORE the
|
||||
// pairing-code regex so the order is unambiguous.
|
||||
if (QR_INSTRUCTIONS_RE.test(body)) return { kind: 'unknown' };
|
||||
if (PAIRING_CODE_INSTRUCTIONS_RE.test(body)) return { kind: 'pairing_code_instructions' };
|
||||
|
||||
// Pairing code body — must be checked AFTER the various error traps
|
||||
// because a Go-error tail could in theory contain an 8-char hyphenated
|
||||
// sequence. In practice the upstream alphabet (1-9 + A-HJ-NP-TV-Z)
|
||||
// doesn't overlap with timestamps or PII tokens, but order matters
|
||||
// for defensiveness.
|
||||
// Skip checking it here at the top — the ordered fall-through later
|
||||
// catches it after error traps.
|
||||
|
||||
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||
if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
|
||||
if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
|
||||
if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
|
||||
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||
if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
|
||||
|
||||
const maxMatch = MAX_LOGINS_RE.exec(body);
|
||||
if (maxMatch) {
|
||||
const limit = Number(maxMatch[1]);
|
||||
return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
|
||||
}
|
||||
|
||||
const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
|
||||
if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
|
||||
|
||||
const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
|
||||
if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
|
||||
|
||||
const invalidValueMatch = INVALID_VALUE_RE.exec(body);
|
||||
if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
|
||||
|
||||
const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
|
||||
if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
|
||||
|
||||
const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
|
||||
if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
|
||||
|
||||
const startFailedMatch = START_FAILED_RE.exec(body);
|
||||
if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
|
||||
|
||||
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
|
||||
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
|
||||
|
||||
// Pairing code body — checked AFTER all error traps so a Go-error tail
|
||||
// matching the pattern by accident doesn't pre-empt a real error
|
||||
// classification. The `^` anchor + character class is strict enough
|
||||
// that false matches against arbitrary text are unlikely.
|
||||
const pairingMatch = PAIRING_CODE_RE.exec(body);
|
||||
if (pairingMatch) return { kind: 'pairing_code_displayed', code: pairingMatch[1] };
|
||||
|
||||
// Fall-through to login-list AFTER the error traps so a row that happens
|
||||
// to start with `* ` mid-error-message doesn't get mistaken for a login
|
||||
// list.
|
||||
const logins = parseLoginList(body);
|
||||
if (logins.length > 0) return { kind: 'logins_listed', logins };
|
||||
|
||||
return { kind: 'unknown' };
|
||||
};
|
||||
|
||||
// --- Full-event parser ---------------------------------------------------
|
||||
//
|
||||
// `parseEventBridgev2V0264` dispatches on `event.type` and routes:
|
||||
//
|
||||
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
|
||||
// redaction's `redacts` against the active QR event id and decides
|
||||
// whether it's a meaningful signal or unrelated cleanup.
|
||||
//
|
||||
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
|
||||
// contains a whatsmeow QR payload (4 comma-separated base64 fields).
|
||||
//
|
||||
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
|
||||
// `parseBridgev2V0264Body(body)` path.
|
||||
|
||||
// Whatsmeow QR data: `<ref>,<base64-noise>,<base64-identity>,<base64-adv>`.
|
||||
// Each field is alphanumeric + base64 fillers + a few extras commonly seen
|
||||
// in `ref` (`@`, `:`, `.`, `-`, `_`). Match exactly 4 comma-separated
|
||||
// non-empty alphanumeric chunks at the start of the string. NO leading
|
||||
// whitespace tolerance because the bridge's `Body: qr` (sendQR in
|
||||
// bridgev2/commands/login.go) is a clean assignment with no prefix.
|
||||
//
|
||||
// Strictness rationale: false-positives here are catastrophic — we'd
|
||||
// emit a `qr_displayed` for an arbitrary text image caption, the state
|
||||
// machine would render its body into a QR matrix, and the user would
|
||||
// see a meaningless QR. The 4-field shape and the alphabet are tight
|
||||
// enough to avoid that against any realistic m.image body.
|
||||
const WA_QR_PAYLOAD_RE = /^[A-Za-z0-9+/=@:_.\-]+(?:,[A-Za-z0-9+/=@:_.\-]+){3}$/;
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
export const parseEventBridgev2V0264 = (event: ParsableEvent): LoginEvent => {
|
||||
if (event.type === 'm.room.redaction') {
|
||||
// `redacts` is mirrored at the top level by the host sanitizer (see
|
||||
// `sanitizeBotWidgetRedactionEvent` in BotWidgetDriver.ts), but check
|
||||
// both spots for forward-compat with future drivers / SDK shapes.
|
||||
const target =
|
||||
typeof event.redacts === 'string'
|
||||
? event.redacts
|
||||
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||
? event.content.redacts
|
||||
: undefined;
|
||||
if (!target) return { kind: 'unknown' };
|
||||
return { kind: 'qr_redacted', redactsEventId: target };
|
||||
}
|
||||
|
||||
if (event.type !== 'm.room.message') return { kind: 'unknown' };
|
||||
|
||||
const msgtype = event.content?.msgtype;
|
||||
|
||||
if (msgtype === 'm.image') {
|
||||
// Edits replace `body` by spec; bridgev2 ALSO mirrors the new payload
|
||||
// into `m.new_content.body`. Prefer `m.new_content.body` when present
|
||||
// (so an older SDK pre-flattening edit content still lets us extract
|
||||
// the rotated QR) and fall back to `body`.
|
||||
const newContent = isObject(event.content['m.new_content'])
|
||||
? (event.content['m.new_content'] as { body?: unknown })
|
||||
: undefined;
|
||||
const editedBody =
|
||||
typeof newContent?.body === 'string' ? newContent.body : undefined;
|
||||
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
|
||||
const body = (editedBody ?? directBody).trim();
|
||||
|
||||
if (!WA_QR_PAYLOAD_RE.test(body)) return { kind: 'unknown' };
|
||||
|
||||
const relatesTo = isObject(event.content['m.relates_to'])
|
||||
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
|
||||
: undefined;
|
||||
const replacesEventId =
|
||||
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
|
||||
? relatesTo.event_id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
kind: 'qr_displayed',
|
||||
qrData: body,
|
||||
eventId: event.event_id,
|
||||
replacesEventId,
|
||||
};
|
||||
}
|
||||
|
||||
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
|
||||
|
||||
const body = typeof event.content.body === 'string' ? event.content.body : '';
|
||||
return parseBridgev2V0264Body(body);
|
||||
};
|
||||
|
||||
// --- DEV sanity assertions -----------------------------------------------
|
||||
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||
// is replaced with the literal `false` and the call site collapses, so the
|
||||
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||
// first regression on reload.
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
runSanityChecks();
|
||||
}
|
||||
|
||||
function runSanityChecks(): void {
|
||||
const cases: Array<[string, LoginEvent]> = [
|
||||
// Shared bridgev2 wordings (verified identical to mautrix-telegram).
|
||||
["You're not logged in", { kind: 'not_logged_in' }],
|
||||
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||
[
|
||||
'Please enter your Phone number\nYour WhatsApp phone number in international format',
|
||||
{ kind: 'awaiting_phone' },
|
||||
],
|
||||
|
||||
// WhatsApp connector-side: success format has NO parens, NO numericId.
|
||||
// Handle is the phone number with leading `+`.
|
||||
[
|
||||
'Successfully logged in as +12345678901',
|
||||
{ kind: 'login_success', handle: '+12345678901' },
|
||||
],
|
||||
// Edge: trailing period, just in case bridgev2 ever adds one.
|
||||
[
|
||||
'Successfully logged in as +12345678901.',
|
||||
{ kind: 'login_success', handle: '+12345678901' },
|
||||
],
|
||||
|
||||
// Logout / cancel — same as Telegram dialect.
|
||||
['Logged out', { kind: 'logout_ok' }],
|
||||
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||
['No ongoing command.', { kind: 'cancel_no_op' }],
|
||||
|
||||
// Login-progress / max-logins / not-found — same as Telegram dialect.
|
||||
[
|
||||
'You already have an ongoing login. You can use `!wa cancel` to cancel it.',
|
||||
{ kind: 'login_in_progress' },
|
||||
],
|
||||
[
|
||||
'You have reached the maximum number of logins (1). Please logout from an existing login before creating a new one. If you want to re-authenticate an existing login, use the `!wa relogin` command.',
|
||||
{ kind: 'max_logins', limit: 1 },
|
||||
],
|
||||
['Login `12345678901.0` not found', { kind: 'login_not_found', loginId: '12345678901.0' }],
|
||||
['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }],
|
||||
|
||||
// flow_required / flow_invalid — bridgev2 emits these because WA
|
||||
// has TWO flows (qr + phone). The widget sends the full command so
|
||||
// these traps are defence-in-depth.
|
||||
[
|
||||
'Please specify a login flow, e.g. `login qr`.\n\n* `qr` - Scan a QR code...\n* `phone` - Input your phone number...\n',
|
||||
{ kind: 'flow_required' },
|
||||
],
|
||||
[
|
||||
'Invalid login flow `wat`. Available options:\n\n* `qr` - ...',
|
||||
{ kind: 'flow_invalid', flowId: 'wat' },
|
||||
],
|
||||
|
||||
// Generic error traps — same shape as Telegram dialect.
|
||||
['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }],
|
||||
[
|
||||
'Failed to submit input: Phone number too short',
|
||||
{ kind: 'submit_failed', reason: 'Phone number too short' },
|
||||
],
|
||||
[
|
||||
'Failed to prepare login process: connector unavailable',
|
||||
{ kind: 'prepare_failed', reason: 'connector unavailable' },
|
||||
],
|
||||
[
|
||||
'Failed to start login: whatsapp connect timeout',
|
||||
{ kind: 'start_failed', reason: 'whatsapp connect timeout' },
|
||||
],
|
||||
|
||||
// Connector login-failed surfacings (verified upstream — every
|
||||
// RespError listed in pkg/connector/login.go funnels through here).
|
||||
[
|
||||
'Login failed: Phone number too short',
|
||||
{ kind: 'login_failed', reason: 'Phone number too short' },
|
||||
],
|
||||
[
|
||||
'Login failed: Phone number must be in international format',
|
||||
{ kind: 'login_failed', reason: 'Phone number must be in international format' },
|
||||
],
|
||||
[
|
||||
'Login failed: Rate limited by WhatsApp',
|
||||
{ kind: 'login_failed', reason: 'Rate limited by WhatsApp' },
|
||||
],
|
||||
[
|
||||
'Login failed: Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
|
||||
{
|
||||
kind: 'login_failed',
|
||||
reason:
|
||||
'Got client outdated error while waiting for QRs. The bridge must be updated to continue.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Login failed: Please enable WhatsApp web multidevice and scan the QR code again.',
|
||||
{
|
||||
kind: 'login_failed',
|
||||
reason: 'Please enable WhatsApp web multidevice and scan the QR code again.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Login failed: Entering code or scanning QR timed out. Please try again.',
|
||||
{
|
||||
kind: 'login_failed',
|
||||
reason: 'Entering code or scanning QR timed out. Please try again.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Login failed: Unexpected event while waiting for login',
|
||||
{ kind: 'login_failed', reason: 'Unexpected event while waiting for login' },
|
||||
],
|
||||
[
|
||||
'Login failed: pair error: invalid signature',
|
||||
{ kind: 'login_failed', reason: 'pair error: invalid signature' },
|
||||
],
|
||||
|
||||
// Pairing-code instructions + the code itself (two separate notices).
|
||||
[
|
||||
'Input the pairing code in the WhatsApp mobile app to log in',
|
||||
{ kind: 'pairing_code_instructions' },
|
||||
],
|
||||
// Code body in two valid shapes — plain and markdown-backticked.
|
||||
['ABCD-1234', { kind: 'pairing_code_displayed', code: 'ABCD-1234' }],
|
||||
['`WXYZ-9876`', { kind: 'pairing_code_displayed', code: 'WXYZ-9876' }],
|
||||
// Spaces around the code — RenderMarkdown sometimes preserves a
|
||||
// leading newline; trim handles it but the regex's `\s*` is belt-
|
||||
// and-suspenders.
|
||||
[' PQRS-4567 ', { kind: 'pairing_code_displayed', code: 'PQRS-4567' }],
|
||||
// Negative case — alphabet excludes I/O/U; an `I` in the slot must
|
||||
// NOT match. Prevents a stray sentence being misread as a code.
|
||||
['ABID-1234', { kind: 'unknown' }],
|
||||
|
||||
// QR Instructions — swallowed silently as `unknown`.
|
||||
[
|
||||
'Scan the QR code with the WhatsApp mobile app to log in',
|
||||
{ kind: 'unknown' },
|
||||
],
|
||||
|
||||
// External logout — three reasons.
|
||||
[
|
||||
'You were logged out from another device. Relogin to continue using the bridge.',
|
||||
{ kind: 'external_logout', reason: 'another_device' },
|
||||
],
|
||||
[
|
||||
'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
|
||||
{ kind: 'external_logout', reason: 'phone_logged_out' },
|
||||
],
|
||||
[
|
||||
'You were logged out for an unknown reason. Relogin to continue using the bridge.',
|
||||
{ kind: 'external_logout', reason: 'unknown' },
|
||||
],
|
||||
// Connector-startup notice — same effect as external_logout.
|
||||
[
|
||||
"You're not logged into WhatsApp. Relogin to continue using the bridge.",
|
||||
{ kind: 'external_logout', reason: 'unknown' },
|
||||
],
|
||||
|
||||
// Connection warnings — surfaced in transcript only.
|
||||
[
|
||||
'Reconnecting to WhatsApp...',
|
||||
{ kind: 'connection_warning', text: 'Reconnecting to WhatsApp...' },
|
||||
],
|
||||
[
|
||||
'Disconnected from WhatsApp. Trying to reconnect.',
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: 'Disconnected from WhatsApp. Trying to reconnect.',
|
||||
},
|
||||
],
|
||||
[
|
||||
"Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
|
||||
},
|
||||
],
|
||||
[
|
||||
'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: 'The WhatsApp web servers are not responding. The bridge will try to reconnect.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Connecting to the WhatsApp web servers failed.',
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: 'Connecting to the WhatsApp web servers failed.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Stream replaced: the bridge was started in another location.',
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: 'Stream replaced: the bridge was started in another location.',
|
||||
},
|
||||
],
|
||||
[
|
||||
// Bridge-image outdated — `Connect failure: 405 client outdated.
|
||||
// Bridge must be updated.` from connector handlewhatsapp.go.
|
||||
// Surfaces as a connection_warning (no state change), the
|
||||
// eventual login_failed will deliver the actionable error.
|
||||
'Connect failure: 405 client outdated. Bridge must be updated.',
|
||||
{
|
||||
kind: 'connection_warning',
|
||||
text: 'Connect failure: 405 client outdated. Bridge must be updated.',
|
||||
},
|
||||
],
|
||||
|
||||
// Relaxed cancel regex — match any leading word + "cancelled." so a
|
||||
// future bridgev2 introducing additional CommandState.Action values
|
||||
// (e.g. "Logout cancelled.") still resolves to cancel_ok. Today
|
||||
// only "Login cancelled." is emitted, but the relaxed match keeps
|
||||
// us robust to upstream drift.
|
||||
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||
['Logout cancelled.', { kind: 'cancel_ok' }],
|
||||
['Relogin cancelled.', { kind: 'cancel_ok' }],
|
||||
|
||||
// Truly unrecognised body — keeps the transcript usable when
|
||||
// bridgev2 wording drifts.
|
||||
[
|
||||
'Some completely unknown bridge reply that does not match any anchor',
|
||||
{ kind: 'unknown' },
|
||||
],
|
||||
|
||||
// Login list with the leading-newline bug (verified present in
|
||||
// bridgev2 user.go:185 — same as Telegram dialect).
|
||||
[
|
||||
'\n* `12345678901.0` (+12345678901) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
|
||||
},
|
||||
],
|
||||
// Same row without the bug — keeps matching after upstream fix.
|
||||
[
|
||||
'* `12345678901.0` (+12345678901) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [{ id: '12345678901.0', name: '+12345678901', state: 'CONNECTED' }],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [body, expected] of cases) {
|
||||
const actual = parseBridgev2V0264Body(body);
|
||||
if (!sameEvent(actual, expected)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[bridgev2_v0264 sanity] mismatch', { body, actual, expected });
|
||||
throw new Error(
|
||||
`bridgev2_v0264 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// parseEventBridgev2V0264 — exercises the full-event dispatch (m.image,
|
||||
// m.room.redaction, m.notice fall-through). Same throw-on-mismatch
|
||||
// pattern as the body-only parser cases above.
|
||||
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
|
||||
[
|
||||
// Canonical whatsmeow QR — 4 comma-separated base64 fields.
|
||||
// This shape comes from go.mau.fi/whatsmeow/pair.go::makeQRData.
|
||||
// The first field (`ref`) typically starts with `2@<base64>`; the
|
||||
// next three are pure base64.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$qr1',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: {
|
||||
msgtype: 'm.image',
|
||||
body: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'qr_displayed',
|
||||
qrData: '2@AbCdEfGhIjKl,bmFtZTE=,aWRlbnQx,YWR2c2Vj',
|
||||
eventId: '$qr1',
|
||||
},
|
||||
],
|
||||
[
|
||||
// QR rotation edit — `m.relates_to.rel_type=m.replace` + new payload
|
||||
// inside `m.new_content.body`. The edited payload must take
|
||||
// precedence over the literal `body`.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$qr2',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: {
|
||||
msgtype: 'm.image',
|
||||
body: '2@OldRef,old1,old2,old3',
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
|
||||
'm.new_content': {
|
||||
msgtype: 'm.image',
|
||||
body: '2@NewRef,new1,new2,new3',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'qr_displayed',
|
||||
qrData: '2@NewRef,new1,new2,new3',
|
||||
eventId: '$qr2',
|
||||
replacesEventId: '$qr1',
|
||||
},
|
||||
],
|
||||
[
|
||||
// Bare m.image without 4-field comma payload — bridge has no business
|
||||
// sending these to the control DM, but if it does we keep the line
|
||||
// as `unknown` (transcript surfaces a diag, no QR-state mutation).
|
||||
// The string has 1 comma → not 4 fields → declined.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$rand',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: { msgtype: 'm.image', body: 'something, unrelated' },
|
||||
},
|
||||
{ kind: 'unknown' },
|
||||
],
|
||||
[
|
||||
// 3 fields (one too few) — declined as `unknown`. Defensive against
|
||||
// a future bridge protocol revision that drops a field; we'd rather
|
||||
// miss the QR than render a malformed login token into a QR matrix.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$shortqr',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: { msgtype: 'm.image', body: 'a,b,c' },
|
||||
},
|
||||
{ kind: 'unknown' },
|
||||
],
|
||||
[
|
||||
// Redaction — top-level `redacts` (host sanitizer mirrors there).
|
||||
{
|
||||
type: 'm.room.redaction',
|
||||
event_id: '$red1',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: { redacts: '$qr1' },
|
||||
redacts: '$qr1',
|
||||
},
|
||||
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
|
||||
],
|
||||
[
|
||||
// Redaction missing target — sanitizer should already reject; defence
|
||||
// in depth.
|
||||
{
|
||||
type: 'm.room.redaction',
|
||||
event_id: '$red2',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: {},
|
||||
},
|
||||
{ kind: 'unknown' },
|
||||
],
|
||||
[
|
||||
// m.notice fall-through — preserves existing body-side parser path.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$n1',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: { msgtype: 'm.notice', body: "You're not logged in" },
|
||||
},
|
||||
{ kind: 'not_logged_in' },
|
||||
],
|
||||
[
|
||||
// m.notice carrying the pairing code — full event-level test.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$pc1',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: { msgtype: 'm.notice', body: 'ABCD-1234' },
|
||||
},
|
||||
{ kind: 'pairing_code_displayed', code: 'ABCD-1234' },
|
||||
],
|
||||
[
|
||||
// m.notice carrying an external-logout notice — full event level.
|
||||
{
|
||||
type: 'm.room.message',
|
||||
event_id: '$xl1',
|
||||
sender: '@whatsappbot:vojo.chat',
|
||||
content: {
|
||||
msgtype: 'm.notice',
|
||||
body: 'Your phone was logged out from WhatsApp. Relogin to continue using the bridge.',
|
||||
},
|
||||
},
|
||||
{ kind: 'external_logout', reason: 'phone_logged_out' },
|
||||
],
|
||||
];
|
||||
|
||||
for (const [event, expected] of eventCases) {
|
||||
const actual = parseEventBridgev2V0264(event);
|
||||
if (!sameEvent(actual, expected)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[bridgev2_v0264 event sanity] mismatch', {
|
||||
event,
|
||||
actual,
|
||||
expected,
|
||||
});
|
||||
throw new Error(
|
||||
`bridgev2_v0264 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
// Shallow JSON-compare the discriminated payload. Good enough for the
|
||||
// small set of structures we emit; deeper equality would only matter if
|
||||
// we returned arbitrary nested data.
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
17
apps/widget-whatsapp/src/bridge-protocol/parser.ts
Normal file
17
apps/widget-whatsapp/src/bridge-protocol/parser.ts
Normal file
|
|
@ -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);
|
||||
122
apps/widget-whatsapp/src/bridge-protocol/types.ts
Normal file
122
apps/widget-whatsapp/src/bridge-protocol/types.ts
Normal file
|
|
@ -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
|
||||
// `<ref>,<base64-noise>,<base64-identity>,<base64-adv>` (4 comma-
|
||||
// separated base64 fields). NEVER appended to transcript verbatim;
|
||||
// the adv-secret segment IS the login token.
|
||||
// - QR rotation interval differs from Telegram: first QR lasts 60s,
|
||||
// then 5 more × 20s each (whatsmeow `qrIntervals`). Total active
|
||||
// window is 2 min 40 s, vs Telegram's 10 min.
|
||||
// - NO 2FA cloud-password flow. Multi-device pairing is single-factor;
|
||||
// the QR scan / pairing-code IS the auth.
|
||||
// - Login success format: `Successfully logged in as +<phone>` — no
|
||||
// parens, no numeric ID. Handle is the phone number itself.
|
||||
// - Pairing-code flow (NEW vs Telegram): bridge replies with two
|
||||
// m.notice messages — the Instructions string then the code itself
|
||||
// wrapped in `<code>…</code>` HTML (host driver strips formatted_body,
|
||||
// leaving the plain `XXXX-XXXX` markdown source in `body`).
|
||||
// - Async session events from the connector: external logout (phone
|
||||
// unlinked the device), connection warnings (transient disconnects).
|
||||
|
||||
export type ListedLogin = {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
// Shape of an inbound event the dialect parser needs to look at. Matches
|
||||
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
|
||||
// here (not in widget-api.ts) so the dialect doesn't import from the
|
||||
// transport layer.
|
||||
export type ParsableEvent = {
|
||||
type: string;
|
||||
event_id: string;
|
||||
sender: string;
|
||||
origin_server_ts?: number;
|
||||
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||
redacts?: string;
|
||||
};
|
||||
|
||||
// Reasons why WhatsApp logged us out asynchronously (not via `!wa logout`).
|
||||
// Carried inside `external_logout` so the UI can pick a wording variant
|
||||
// that matches the user's understanding ("phone unlinked from settings"
|
||||
// vs "another linked device kicked us out").
|
||||
export type ExternalLogoutReason = 'another_device' | 'phone_logged_out' | 'unknown';
|
||||
|
||||
export type LoginEvent =
|
||||
// --- shared bridgev2 command replies (same wording as Telegram) ---------
|
||||
| { kind: 'logins_listed'; logins: ListedLogin[] }
|
||||
| { kind: 'not_logged_in' }
|
||||
| { kind: 'awaiting_phone' }
|
||||
| { kind: 'login_success'; handle: string }
|
||||
| { kind: 'logout_ok' }
|
||||
| { kind: 'cancel_ok' }
|
||||
| { kind: 'cancel_no_op' }
|
||||
| { kind: 'login_in_progress' }
|
||||
| { kind: 'max_logins'; limit?: number }
|
||||
| { kind: 'login_not_found'; loginId?: string }
|
||||
| { kind: 'flow_required' }
|
||||
| { kind: 'flow_invalid'; flowId?: string }
|
||||
| { kind: 'unknown_command' }
|
||||
| { kind: 'invalid_value'; reason?: string }
|
||||
// Generic Go-error trap from bridgev2/commands/login.go's display-and-
|
||||
// wait branch (`Login failed: <err>`). For mautrix-whatsapp every
|
||||
// connector-side login error funnels through here:
|
||||
// - `Phone number too short`
|
||||
// - `Phone number must be in international format`
|
||||
// - `Rate limited by WhatsApp`
|
||||
// - `Got client outdated error while waiting for QRs. The bridge
|
||||
// must be updated to continue.`
|
||||
// - `Please enable WhatsApp web multidevice and scan the QR code
|
||||
// again.`
|
||||
// - `Entering code or scanning QR timed out. Please try again.`
|
||||
// - `Unexpected event while waiting for login`
|
||||
// - `Pair error: <err>` (specific PairError surfacing)
|
||||
// The widget keeps the verbatim reason string and does NOT sub-classify
|
||||
// — the upstream wording is structured enough that the user can read it.
|
||||
| { kind: 'login_failed'; reason?: string }
|
||||
| { kind: 'submit_failed'; reason?: string }
|
||||
| { kind: 'prepare_failed'; reason?: string }
|
||||
| { kind: 'start_failed'; reason?: string }
|
||||
// --- QR-flow lifecycle (m.image broadcasts, m.room.redaction cleanup) ---
|
||||
// `qrData` is the raw whatsmeow payload — keep it OUT of any DOM-level
|
||||
// log. The state machine renders it into a QR matrix client-side; once
|
||||
// rendered the matrix is harmless (a screenshot of it would be stale by
|
||||
// the next rotation), but the raw string itself should never be append-
|
||||
// ed to the transcript.
|
||||
| { kind: 'qr_displayed'; qrData: string; eventId: string; replacesEventId?: string }
|
||||
| { kind: 'qr_redacted'; redactsEventId: string }
|
||||
// --- Pairing-code flow (WhatsApp-specific) ------------------------------
|
||||
// First of two notices after a phone-number submit on `!wa login phone`:
|
||||
// `Input the pairing code in the WhatsApp mobile app to log in`. The
|
||||
// state machine flips into a "pairing code is coming" interstitial on
|
||||
// this event so the user sees an immediate change after submit.
|
||||
| { kind: 'pairing_code_instructions' }
|
||||
// Second of the two notices: the actual `XXXX-XXXX` code. The state
|
||||
// machine flips to `pairing_code_shown{code}` and the UI renders the
|
||||
// code prominently with copy-friendly letter-spacing.
|
||||
| { kind: 'pairing_code_displayed'; code: string }
|
||||
// --- Async post-login session events (connector-emitted m.notice) -------
|
||||
// External logout — the bridge lost its session because the phone or
|
||||
// another linked device unlinked us. Routes the live state to
|
||||
// disconnected with a `lastError` flag so the UI surfaces a banner.
|
||||
| { kind: 'external_logout'; reason: ExternalLogoutReason }
|
||||
// Soft connection warnings — `Reconnecting to WhatsApp...`, `Disconnected
|
||||
// from WhatsApp. Trying to reconnect.`, `Your phone hasn't been seen…`.
|
||||
// The widget surfaces these in the transcript only; state isn't
|
||||
// touched (the bridge is still operational, just having a hiccup).
|
||||
| { kind: 'connection_warning'; text: string }
|
||||
| { kind: 'unknown' };
|
||||
116
apps/widget-whatsapp/src/i18n/en.ts
Normal file
116
apps/widget-whatsapp/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// English fallback. Mirror the RU key set; `Record<StringKey, string>`
|
||||
// enforces every RU key has an EN counterpart at compile time.
|
||||
|
||||
import type { StringKey } from './ru';
|
||||
|
||||
export const EN: Record<StringKey, string> = {
|
||||
'status.unknown': 'Checking status…',
|
||||
'status.disconnected': 'WhatsApp not linked',
|
||||
'status.connected': 'WhatsApp linked',
|
||||
'status.connected-as': 'WhatsApp linked as {handle}',
|
||||
'status.logging-out': 'Signing out…',
|
||||
'status.qr-verifying': 'Verifying sign-in…',
|
||||
'status.pairing-verifying': 'Verifying sign-in…',
|
||||
'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}.',
|
||||
};
|
||||
30
apps/widget-whatsapp/src/i18n/index.ts
Normal file
30
apps/widget-whatsapp/src/i18n/index.ts
Normal file
|
|
@ -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, string>): string => {
|
||||
if (!vars) return s;
|
||||
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||
};
|
||||
|
||||
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
|
||||
const lang = (
|
||||
clientLanguage ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : '') ||
|
||||
'ru'
|
||||
).toLowerCase();
|
||||
return lang.startsWith('en') ? EN : RU;
|
||||
};
|
||||
|
||||
export type T = (key: StringKey, vars?: Record<string, string>) => string;
|
||||
|
||||
export const createT = (clientLanguage?: string): T => {
|
||||
const dict = pickDict(clientLanguage);
|
||||
return (key, vars) => interpolate(dict[key], vars);
|
||||
};
|
||||
|
||||
export type { StringKey };
|
||||
211
apps/widget-whatsapp/src/i18n/ru.ts
Normal file
211
apps/widget-whatsapp/src/i18n/ru.ts
Normal file
|
|
@ -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;
|
||||
79
apps/widget-whatsapp/src/main.tsx
Normal file
79
apps/widget-whatsapp/src/main.tsx
Normal file
|
|
@ -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(
|
||||
<div class="app">
|
||||
<div class="error-banner">
|
||||
<strong>{t('bootstrap.failed')}</strong>
|
||||
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
|
||||
{t('bootstrap.embedded-only', { route: '/bots/whatsapp' })}
|
||||
</div>
|
||||
</div>,
|
||||
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(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||
}
|
||||
1026
apps/widget-whatsapp/src/state.ts
Normal file
1026
apps/widget-whatsapp/src/state.ts
Normal file
File diff suppressed because it is too large
Load diff
1103
apps/widget-whatsapp/src/styles.css
Normal file
1103
apps/widget-whatsapp/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
1
apps/widget-whatsapp/src/vite-env.d.ts
vendored
Normal file
1
apps/widget-whatsapp/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
327
apps/widget-whatsapp/src/widget-api.ts
Normal file
327
apps/widget-whatsapp/src/widget-api.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// Minimal matrix-widget-api transport implemented inline. We don't pull
|
||||
// the full SDK because:
|
||||
// - it's CommonJS and forces ESM interop juggling that we hit on the
|
||||
// dev fixture in the Telegram widget's M2 phase (esm.sh wrapping made
|
||||
// WidgetApi unavailable as a constructor);
|
||||
// - the surface we use is small: capabilities reply, theme_change reply,
|
||||
// send_event request, read_events request, get_openid request, live
|
||||
// event delivery via send_event toWidget.
|
||||
//
|
||||
// Protocol shapes match
|
||||
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||
// (in the host repo). Default request timeout on the host transport is
|
||||
// 10 s — keep that in mind for bridge-bot replies that take time.
|
||||
|
||||
import type { WidgetBootstrap } from './bootstrap';
|
||||
|
||||
export type RoomEvent = {
|
||||
type: string;
|
||||
event_id: string;
|
||||
room_id: string;
|
||||
sender: string;
|
||||
origin_server_ts: number;
|
||||
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||
unsigned: Record<string, unknown>;
|
||||
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
||||
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
||||
// for forward-compat; the widget-side parser reads either.
|
||||
redacts?: string;
|
||||
};
|
||||
|
||||
type ToWidgetMessage = {
|
||||
api: 'toWidget';
|
||||
widgetId: string;
|
||||
requestId: string;
|
||||
action: string;
|
||||
data: Record<string, unknown>;
|
||||
// Present when this message IS a reply to a prior toWidget request.
|
||||
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
||||
// `api` field and add `response`. Both directions follow the same shape.
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FromWidgetMessage = {
|
||||
api: 'fromWidget';
|
||||
widgetId: string;
|
||||
requestId: string;
|
||||
action: string;
|
||||
data: Record<string, unknown>;
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Capability = string;
|
||||
|
||||
export type WidgetApiEvents = {
|
||||
ready: () => void;
|
||||
liveEvent: (ev: RoomEvent) => void;
|
||||
themeChange: (name: 'light' | 'dark') => void;
|
||||
};
|
||||
|
||||
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WidgetApi {
|
||||
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
|
||||
private requestSeq = 0;
|
||||
|
||||
private isReady = false;
|
||||
|
||||
public constructor(
|
||||
private readonly bootstrap: WidgetBootstrap,
|
||||
private readonly capabilities: Capability[]
|
||||
) {
|
||||
window.addEventListener('message', this.onMessage);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
window.removeEventListener('message', this.onMessage);
|
||||
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
||||
list.push(listener);
|
||||
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||
// before this listener attached (cached-bundle race: host fires the
|
||||
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||
// resolves it during script init, then React's useEffect runs *after*
|
||||
// that and attaches the `ready` listener), replay synchronously so
|
||||
// App.tsx still flips `handshakeOk` and fires `list-logins`.
|
||||
if (event === 'ready' && this.isReady) {
|
||||
(listener as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
public sendText(body: string): Promise<{ event_id: string }> {
|
||||
return this.fromWidget('send_event', {
|
||||
type: 'm.room.message',
|
||||
content: { msgtype: 'm.text', body },
|
||||
}) as Promise<{ event_id: string }>;
|
||||
}
|
||||
|
||||
// Open an external URL via the host. The host receives this on a
|
||||
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
|
||||
// matrix-widget-api's `fromWidget` so it doesn't route through
|
||||
// ClientWidgetApi's request/response machinery.
|
||||
//
|
||||
// Why this exists: cross-origin iframes inside Capacitor's Android
|
||||
// WebView silently drop `<a target="_blank">` clicks — the WebView
|
||||
// doesn't have a multi-window concept, and the host's global
|
||||
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
|
||||
// inside the host document, not inside the iframe (cross-origin
|
||||
// events don't bubble across the frame boundary). The widget posts
|
||||
// this message instead; the host calls `openExternalUrl(url)` which
|
||||
// routes to `Browser.open` on native and `window.open` on web.
|
||||
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 `<commandPrefix> ` (trailing space —
|
||||
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
||||
// the management room and any other room the bot may have been moved to.
|
||||
// Form-field submissions (phone number) go through this same helper because
|
||||
// bridgev2's stored CommandState fallback only fires after queue.go:108
|
||||
// routes the message — and that route also requires the prefix outside the
|
||||
// management room.
|
||||
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||
return this.sendText(body);
|
||||
}
|
||||
|
||||
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
||||
// capability is MSC2762 timeline (already requested at construction). We
|
||||
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
||||
// ClientWidgetApi takes the modern code path that calls our driver's
|
||||
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
||||
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
||||
// to chronological order is the caller's job.
|
||||
//
|
||||
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
||||
// post-scan cleanup events. `msgtype` is honoured only for m.room.message
|
||||
// (matches the driver's `readRoomTimeline` semantics).
|
||||
public async readTimeline(opts: {
|
||||
limit: number;
|
||||
type?: 'm.room.message' | 'm.room.redaction';
|
||||
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
||||
}): Promise<RoomEvent[]> {
|
||||
const data: Record<string, unknown> = {
|
||||
type: opts.type ?? 'm.room.message',
|
||||
limit: opts.limit,
|
||||
room_ids: [this.bootstrap.roomId],
|
||||
};
|
||||
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
||||
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
||||
return (res.events as RoomEvent[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
private emit<K extends keyof WidgetApiEvents>(
|
||||
event: K,
|
||||
...args: Parameters<WidgetApiEvents[K]>
|
||||
): void {
|
||||
const list = this.listeners[event] as
|
||||
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
|
||||
| undefined;
|
||||
list?.forEach((fn) => fn(...args));
|
||||
}
|
||||
|
||||
private nextRequestId(): string {
|
||||
this.requestSeq += 1;
|
||||
return `widget-wa-${Date.now()}-${this.requestSeq}`;
|
||||
}
|
||||
|
||||
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
|
||||
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
|
||||
}
|
||||
|
||||
private onMessage = (ev: MessageEvent): void => {
|
||||
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||
// Source-window guard: every legit widget API message comes from the
|
||||
// host window that embedded our iframe — i.e. window.parent. A foreign
|
||||
// tab/frame on the same origin (think browser extension content
|
||||
// script, popup, or sibling iframe) could otherwise post a forged
|
||||
// message that passes the origin check. We only accept messages
|
||||
// whose `source` is literally `window.parent`. The `widgetId` check
|
||||
// a few lines down is a soft filter; this is the hard one.
|
||||
if (ev.source !== window.parent) return;
|
||||
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||
|
||||
if (msg.api === 'toWidget') {
|
||||
this.handleToWidget(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.api === 'fromWidget' && msg.response) {
|
||||
const pending = this.pending.get(msg.requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(msg.requestId);
|
||||
const err = (msg.response as { error?: { message?: string } }).error;
|
||||
if (err) pending.reject(new Error(err.message ?? 'request failed'));
|
||||
else pending.resolve(msg.response);
|
||||
}
|
||||
};
|
||||
|
||||
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
|
||||
this.postToHost({
|
||||
api: msg.api,
|
||||
widgetId: msg.widgetId,
|
||||
requestId: msg.requestId,
|
||||
action: msg.action,
|
||||
data: msg.data,
|
||||
response,
|
||||
});
|
||||
}
|
||||
|
||||
private handleToWidget(msg: ToWidgetMessage): void {
|
||||
if (!msg.requestId || !msg.action) return;
|
||||
switch (msg.action) {
|
||||
case 'capabilities': {
|
||||
this.replyTo(msg, { capabilities: this.capabilities });
|
||||
return;
|
||||
}
|
||||
case 'notify_capabilities': {
|
||||
this.replyTo(msg, {});
|
||||
if (!this.isReady) {
|
||||
this.isReady = true;
|
||||
this.emit('ready');
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'supported_api_versions': {
|
||||
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
||||
return;
|
||||
}
|
||||
case 'theme_change': {
|
||||
const name = (msg.data?.name as string | undefined) ?? '';
|
||||
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
|
||||
this.emit('themeChange', themed);
|
||||
this.replyTo(msg, {});
|
||||
return;
|
||||
}
|
||||
case 'send_event': {
|
||||
// Live event push from host. Forward `m.room.message` (carries the
|
||||
// bot's notices / errors / `m.image` QR-login broadcasts AND the
|
||||
// pairing-code text) AND `m.room.redaction` (post-scan QR cleanup,
|
||||
// see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State
|
||||
// events (m.room.member) also arrive on this channel — we still
|
||||
// ignore them here.
|
||||
const data = msg.data as Partial<RoomEvent> | undefined;
|
||||
if (
|
||||
data &&
|
||||
data.event_id &&
|
||||
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
||||
) {
|
||||
this.emit('liveEvent', data as RoomEvent);
|
||||
}
|
||||
this.replyTo(msg, {});
|
||||
return;
|
||||
}
|
||||
case 'update_state': {
|
||||
// Initial room state push from host (m.room.member members).
|
||||
// We don't use these yet; future milestones can use it for header chrome.
|
||||
this.replyTo(msg, {});
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
// Be liberal — reply empty so the host's request promise resolves.
|
||||
this.replyTo(msg, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fromWidget(
|
||||
action: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = this.nextRequestId();
|
||||
this.pending.set(requestId, { resolve, reject });
|
||||
this.postToHost({
|
||||
api: 'fromWidget',
|
||||
widgetId: this.bootstrap.widgetId,
|
||||
requestId,
|
||||
action,
|
||||
data,
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (this.pending.has(requestId)) {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
|
||||
}
|
||||
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
|
||||
// Anything else is silently dropped by the host's validateCapabilities.
|
||||
//
|
||||
// `m.image` and `m.room.redaction` are the QR-login additions (already in
|
||||
// place from the Telegram widget M13). The host sanitizer for `m.image`
|
||||
// strips `url` / `file` / `info`, leaving only `body` (the bridge encodes
|
||||
// the QR payload there) plus `m.relates_to` / `m.new_content` for QR
|
||||
// rotation edits. Redactions signal that the QR was consumed by a
|
||||
// successful scan.
|
||||
export const buildCapabilities = (roomId: string): Capability[] => [
|
||||
`org.matrix.msc2762.timeline:${roomId}`,
|
||||
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
||||
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
||||
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
||||
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
||||
'org.matrix.msc2762.receive.event:m.room.redaction',
|
||||
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||
];
|
||||
21
apps/widget-whatsapp/tsconfig.json
Normal file
21
apps/widget-whatsapp/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
27
apps/widget-whatsapp/vite.config.ts
Normal file
27
apps/widget-whatsapp/vite.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
|
||||
// Build artefact lives at apps/widget-whatsapp/dist/. The deploy step
|
||||
// (out of repo) rsyncs this into ~/vojo/widgets/whatsapp/ on the server,
|
||||
// which Caddy serves from /var/www/widgets/whatsapp via the
|
||||
// widgets.vojo.chat block.
|
||||
//
|
||||
// `base: './'` keeps every generated asset path relative so the same
|
||||
// build can sit under /whatsapp/ on widgets.vojo.chat without rewrites.
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
target: 'es2020',
|
||||
sourcemap: true,
|
||||
// Inline CSS for a single round-trip; the widget is small and the
|
||||
// host's iframe handshake budget is already tight (10s default).
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
server: {
|
||||
// Different port from widget-telegram (8081) and widget-discord (8082)
|
||||
// so all three can run side-by-side during local development.
|
||||
port: 8083,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
10
config.json
10
config.json
|
|
@ -33,6 +33,16 @@
|
|||
"url": "https://widgets.vojo.chat/discord/index.html",
|
||||
"commandPrefix": "!discord"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "whatsapp",
|
||||
"mxid": "@whatsappbot:vojo.chat",
|
||||
"name": "WhatsApp",
|
||||
"experience": {
|
||||
"type": "matrix-widget",
|
||||
"url": "https://widgets.vojo.chat/whatsapp/index.html",
|
||||
"commandPrefix": "!wa"
|
||||
}
|
||||
}
|
||||
],
|
||||
"push": {
|
||||
|
|
|
|||
|
|
@ -898,11 +898,13 @@
|
|||
"more_options": "More",
|
||||
"description": {
|
||||
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
|
||||
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app."
|
||||
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",
|
||||
"whatsapp": "Connect WhatsApp to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal WhatsApp messages. Sign-in uses a QR code or pairing code from the WhatsApp mobile app."
|
||||
},
|
||||
"description_short": {
|
||||
"telegram": "Telegram chat connection",
|
||||
"discord": "Discord chat connection"
|
||||
"discord": "Discord chat connection",
|
||||
"whatsapp": "WhatsApp chat connection"
|
||||
},
|
||||
"unknown_title": "Robot not found",
|
||||
"unknown_description": "This robot is not in the Vojo catalog."
|
||||
|
|
|
|||
|
|
@ -902,11 +902,13 @@
|
|||
"more_options": "Ещё",
|
||||
"description": {
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord."
|
||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
|
||||
"whatsapp": "Подключите WhatsApp к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в WhatsApp как обычные сообщения. Вход — через QR-код или 8-символьный код из мобильного WhatsApp."
|
||||
},
|
||||
"description_short": {
|
||||
"telegram": "Подключение чатов Telegram",
|
||||
"discord": "Подключение чатов Discord"
|
||||
"discord": "Подключение чатов Discord",
|
||||
"whatsapp": "Подключение чатов WhatsApp"
|
||||
},
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
type WidgetDriver,
|
||||
} from 'matrix-widget-api';
|
||||
import { Theme } from '../../hooks/useTheme';
|
||||
import { openExternalUrl } from '../../utils/capacitor';
|
||||
import type { BotPreset } from './catalog';
|
||||
import {
|
||||
BotWidgetDriver,
|
||||
|
|
@ -93,31 +94,46 @@ const createBotIframe = (preset: BotPreset): HTMLIFrameElement => {
|
|||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.title = `${preset.name} Bot`;
|
||||
// Sandbox aligns with docs/plans/bots_tab.md M8 minimum: scripts + forms +
|
||||
// same-origin only. Add allow-popups / allow-popups-to-escape-sandbox /
|
||||
// allow-downloads only when a specific widget requires them (e.g. an OAuth
|
||||
// login flow), as a per-preset opt-in — not as a default. Element-Web's
|
||||
// wider default exists because their widget set includes Element Call;
|
||||
// Phase 2 bot widgets are text-protocol management surfaces.
|
||||
// Sandbox baseline: scripts + forms + same-origin (per docs/plans/
|
||||
// bots_tab.md M8). Plus `allow-popups` + `allow-popups-to-escape-
|
||||
// sandbox` so widget-side `<a target="_blank">` actually opens —
|
||||
// every shipped widget surfaces external links (mautrix bridge
|
||||
// GitHub repos in About modals, Meta ToS in the WhatsApp warning
|
||||
// modal) and without popups those clicks are silently dropped.
|
||||
// `allow-popups-to-escape-sandbox` lets the opened tab run without
|
||||
// inheriting our sandbox, which is what users expect when leaving
|
||||
// the iframe for an external site.
|
||||
//
|
||||
// Threat-model honesty: the sandbox here is STRUCTURAL, not adversarial.
|
||||
// `allow-downloads` stays off — no current widget needs to deliver
|
||||
// downloadable content. Add it as a per-preset opt-in if a future
|
||||
// widget genuinely needs it.
|
||||
//
|
||||
// Threat-model honesty: the sandbox here is STRUCTURAL, not
|
||||
// adversarial.
|
||||
// (a) The widget is served cross-origin (widgets.vojo.chat in prod,
|
||||
// localhost:8081 in dev) so the documented `allow-scripts` +
|
||||
// `allow-same-origin` same-origin-escape doesn't apply — same-origin
|
||||
// refers to the iframe's OWN origin, not the host's. The widget can't
|
||||
// read host (vojo.chat) localStorage / cookies because it's a
|
||||
// different origin entirely.
|
||||
// (b) The actual security boundary against a compromised widget bundle
|
||||
// is BotWidgetDriver — capability allowlist, sanitizer (only
|
||||
// `m.text`/`m.notice`/`m.image` fields the bridge needs, no mxc /
|
||||
// file / info), strict 1:1 room invariant in `isSafeBotWidgetRoom`.
|
||||
// A hostile bundle that somehow shipped would still see only the
|
||||
// events the driver hands it.
|
||||
// `allow-same-origin` same-origin-escape doesn't apply — same-
|
||||
// origin refers to the iframe's OWN origin, not the host's. The
|
||||
// widget can't read host (vojo.chat) localStorage / cookies
|
||||
// because it's a different origin entirely.
|
||||
// (b) The actual security boundary against a compromised widget
|
||||
// bundle is BotWidgetDriver — capability allowlist, sanitizer
|
||||
// (only `m.text` / `m.notice` / `m.image` fields the bridge
|
||||
// needs, no mxc / file / info), strict 1:1 room invariant in
|
||||
// `isSafeBotWidgetRoom`. A hostile bundle would still see only
|
||||
// the events the driver hands it.
|
||||
// (c) Popups require user interaction (a click) to open under modern
|
||||
// browser pop-up blockers, so a bundle can't spam-open windows
|
||||
// in the background.
|
||||
// If we ever serve the widget same-origin (e.g. inlined as a static
|
||||
// bundle under /widgets/ on vojo.chat), drop `allow-same-origin` here —
|
||||
// the postMessage transport doesn't need it, and the same-origin
|
||||
// sandbox-escape becomes real once the iframe shares the host's origin.
|
||||
iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin');
|
||||
// bundle under /widgets/ on vojo.chat), drop `allow-same-origin` here
|
||||
// — the postMessage transport doesn't need it, and the same-origin
|
||||
// sandbox-escape becomes real once the iframe shares the host's
|
||||
// origin.
|
||||
iframe.setAttribute(
|
||||
'sandbox',
|
||||
'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
);
|
||||
iframe.allow = 'clipboard-write';
|
||||
iframe.referrerPolicy = 'no-referrer';
|
||||
iframe.style.width = '100%';
|
||||
|
|
@ -143,6 +159,16 @@ export class BotWidgetEmbed {
|
|||
|
||||
private readonly disposables: Array<() => void> = [];
|
||||
|
||||
// Expected origin of the widget iframe — captured once at construction
|
||||
// from `iframe.src`. Used by `onWidgetMessage` to validate inbound
|
||||
// postMessages: a source-window check alone is NOT sufficient because
|
||||
// a compromised widget bundle could `window.location.href = '<attacker>'`
|
||||
// and the browser keeps the same WindowProxy across same-frame
|
||||
// navigation, so `iframe.contentWindow` would still match. Pinning
|
||||
// `ev.origin` to the original widget origin closes that gap (see
|
||||
// PortSwigger: «Controlling the web message source»).
|
||||
private widgetOrigin = '';
|
||||
|
||||
// Dedup events that have already been forwarded to the widget. Encrypted DMs
|
||||
// hit feedEvent twice in succession (once from RoomEvent.Timeline with the
|
||||
// ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure
|
||||
|
|
@ -175,6 +201,59 @@ export class BotWidgetEmbed {
|
|||
this.feedStateUpdate(ev);
|
||||
};
|
||||
|
||||
// Side-channel postMessage handler for the widget's `openExternalUrl`
|
||||
// call. Distinct from matrix-widget-api's `fromWidget` channel
|
||||
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
|
||||
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
||||
// of our extension and avoids the «unknown action» reply path.
|
||||
//
|
||||
// Why this exists: the host's global `setupExternalLinkHandler`
|
||||
// (utils/capacitor.ts) intercepts `<a target="_blank">` clicks at
|
||||
// the host document level and routes them via Capacitor's Browser
|
||||
// plugin. But cross-origin iframes don't bubble click events into
|
||||
// the parent document, so widget-side links are invisible to it —
|
||||
// on Capacitor's Android WebView those clicks silently disappear.
|
||||
// The widget posts this message; we validate the URL and forward
|
||||
// to the same `openExternalUrl` helper the host uses elsewhere.
|
||||
//
|
||||
// Security gates (defence in depth):
|
||||
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
|
||||
// check, a compromised widget bundle could `window.location.href
|
||||
// = 'https://attacker.example/'` — the browser keeps the same
|
||||
// WindowProxy across same-frame navigation, so `iframe.contentWindow`
|
||||
// stays equal even after the iframe is hijacked. With it, only
|
||||
// messages from the original widget origin are honoured. See
|
||||
// PortSwigger «Controlling the web message source»:
|
||||
// https://portswigger.net/web-security/dom-based/controlling-the-web-message-source
|
||||
// 2. Source must also be our iframe's contentWindow (an unrelated
|
||||
// iframe of the SAME origin — e.g. an ad embed loaded into a
|
||||
// sibling frame on the same origin in a future deployment —
|
||||
// could otherwise pass the origin check).
|
||||
// 3. Only https URLs are honoured. We tightened from http+https to
|
||||
// https-only because no shipped widget content links over plain
|
||||
// http; rejecting http closes a cleartext-redirect vector via
|
||||
// Capacitor `Browser.open` on Android.
|
||||
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
|
||||
private readonly onWidgetMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== this.widgetOrigin) return;
|
||||
if (ev.source !== this.iframe.contentWindow) return;
|
||||
const msg = ev.data as
|
||||
| { api?: unknown; action?: unknown; data?: { url?: unknown } }
|
||||
| undefined;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.api !== 'io.vojo.bot-widget') return;
|
||||
if (msg.action !== 'open-external-url') return;
|
||||
const url = msg.data?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(url);
|
||||
};
|
||||
|
||||
public constructor(private readonly options: BotWidgetEmbedOptions) {
|
||||
const { mx, room, preset, container, theme, language } = options;
|
||||
const widget = createBotWidget(preset, room, mx, theme, language);
|
||||
|
|
@ -184,6 +263,13 @@ export class BotWidgetEmbed {
|
|||
clientLanguage: language,
|
||||
widgetRoomId: room.roomId,
|
||||
});
|
||||
// Pin the expected origin BEFORE the iframe loads — `onWidgetMessage`
|
||||
// will reject anything whose `ev.origin` doesn't match. `new URL`
|
||||
// resolves a relative `widgetUrl` (e.g. dev `/widgets/...`) against
|
||||
// the host's origin, so this works whether the widget is served
|
||||
// cross-origin (prod widgets.vojo.chat) or same-origin (dev local
|
||||
// bundle / future inlined deployment).
|
||||
this.widgetOrigin = new URL(widgetUrl, window.location.origin).origin;
|
||||
|
||||
// Strict ordering — DO NOT reorder:
|
||||
// 1. Build iframe with NO src.
|
||||
|
|
@ -236,6 +322,7 @@ export class BotWidgetEmbed {
|
|||
room.on(RoomEvent.Timeline, this.onTimelineEvent);
|
||||
mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
mx.on(RoomStateEvent.Events, this.onStateUpdate);
|
||||
window.addEventListener('message', this.onWidgetMessage);
|
||||
|
||||
this.disposables.push(
|
||||
() => api.off('ready', onReady),
|
||||
|
|
@ -243,7 +330,8 @@ export class BotWidgetEmbed {
|
|||
() => iframe.removeEventListener('error', onIframeError),
|
||||
() => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent),
|
||||
() => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted),
|
||||
() => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate)
|
||||
() => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate),
|
||||
() => window.removeEventListener('message', this.onWidgetMessage)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue