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:
heaven 2026-05-05 15:25:16 +03:00
parent 5eb12f888b
commit bc360e84cc
27 changed files with 7870 additions and 26 deletions

View file

@ -445,6 +445,27 @@ export function App({ bootstrap, api }: Props) {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = 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'>) => { const append = useCallback((line: Omit<TranscriptLine, 'id' | 'ts'>) => {
setTranscript((prev) => { setTranscript((prev) => {
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];

View file

@ -101,6 +101,30 @@ export class WidgetApi {
}) as Promise<{ event_id: string }>; }) 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). // Always prefix outbound commands with `<commandPrefix> ` (trailing space).
// Legacy mautrix-discord routes management-room commands through the // Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the // bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -856,6 +856,27 @@ export function App({ bootstrap, api }: Props) {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = 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'>) => { const append = useCallback((line: Omit<TranscriptLine, 'id' | 'ts'>) => {
setTranscript((prev) => { setTranscript((prev) => {
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];

View file

@ -105,6 +105,30 @@ export class WidgetApi {
}) as Promise<{ event_id: string }>; }) 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 — // Always prefix outbound commands with `<commandPrefix> ` (trailing space —
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both // 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. // the management room and any other room the bot may have been moved to.

View 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

File diff suppressed because it is too large Load diff

View 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'),
},
};
};

View file

@ -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);
}

View 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);

View 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' };

View 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, WhatsApps 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 bots 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 Vojos 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}.',
};

View 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 };

View 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;

View 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);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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',
];

View 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"]
}

View 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,
},
});

View file

@ -33,6 +33,16 @@
"url": "https://widgets.vojo.chat/discord/index.html", "url": "https://widgets.vojo.chat/discord/index.html",
"commandPrefix": "!discord" "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": { "push": {

View file

@ -898,11 +898,13 @@
"more_options": "More", "more_options": "More",
"description": { "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.", "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": { "description_short": {
"telegram": "Telegram chat connection", "telegram": "Telegram chat connection",
"discord": "Discord chat connection" "discord": "Discord chat connection",
"whatsapp": "WhatsApp chat connection"
}, },
"unknown_title": "Robot not found", "unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog." "unknown_description": "This robot is not in the Vojo catalog."

View file

@ -902,11 +902,13 @@
"more_options": "Ещё", "more_options": "Ещё",
"description": { "description": {
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.", "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": { "description_short": {
"telegram": "Подключение чатов Telegram", "telegram": "Подключение чатов Telegram",
"discord": "Подключение чатов Discord" "discord": "Подключение чатов Discord",
"whatsapp": "Подключение чатов WhatsApp"
}, },
"unknown_title": "Робот не найден", "unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo." "unknown_description": "Этого робота нет в каталоге Vojo."

View file

@ -17,6 +17,7 @@ import {
type WidgetDriver, type WidgetDriver,
} from 'matrix-widget-api'; } from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme'; import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { import {
BotWidgetDriver, BotWidgetDriver,
@ -93,31 +94,46 @@ const createBotIframe = (preset: BotPreset): HTMLIFrameElement => {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.title = `${preset.name} Bot`; iframe.title = `${preset.name} Bot`;
// Sandbox aligns with docs/plans/bots_tab.md M8 minimum: scripts + forms + // Sandbox baseline: scripts + forms + same-origin (per docs/plans/
// same-origin only. Add allow-popups / allow-popups-to-escape-sandbox / // bots_tab.md M8). Plus `allow-popups` + `allow-popups-to-escape-
// allow-downloads only when a specific widget requires them (e.g. an OAuth // sandbox` so widget-side `<a target="_blank">` actually opens —
// login flow), as a per-preset opt-in — not as a default. Element-Web's // every shipped widget surfaces external links (mautrix bridge
// wider default exists because their widget set includes Element Call; // GitHub repos in About modals, Meta ToS in the WhatsApp warning
// Phase 2 bot widgets are text-protocol management surfaces. // 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, // (a) The widget is served cross-origin (widgets.vojo.chat in prod,
// localhost:8081 in dev) so the documented `allow-scripts` + // localhost:8081 in dev) so the documented `allow-scripts` +
// `allow-same-origin` same-origin-escape doesn't apply — same-origin // `allow-same-origin` same-origin-escape doesn't apply — same-
// refers to the iframe's OWN origin, not the host's. The widget can't // origin refers to the iframe's OWN origin, not the host's. The
// read host (vojo.chat) localStorage / cookies because it's a // widget can't read host (vojo.chat) localStorage / cookies
// different origin entirely. // because it's a different origin entirely.
// (b) The actual security boundary against a compromised widget bundle // (b) The actual security boundary against a compromised widget
// is BotWidgetDriver — capability allowlist, sanitizer (only // bundle is BotWidgetDriver — capability allowlist, sanitizer
// `m.text`/`m.notice`/`m.image` fields the bridge needs, no mxc / // (only `m.text` / `m.notice` / `m.image` fields the bridge
// file / info), strict 1:1 room invariant in `isSafeBotWidgetRoom`. // needs, no mxc / file / info), strict 1:1 room invariant in
// A hostile bundle that somehow shipped would still see only the // `isSafeBotWidgetRoom`. A hostile bundle would still see only
// events the driver hands it. // 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 // 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 — // bundle under /widgets/ on vojo.chat), drop `allow-same-origin` here
// the postMessage transport doesn't need it, and the same-origin // — the postMessage transport doesn't need it, and the same-origin
// sandbox-escape becomes real once the iframe shares the host's origin. // sandbox-escape becomes real once the iframe shares the host's
iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin'); // origin.
iframe.setAttribute(
'sandbox',
'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
);
iframe.allow = 'clipboard-write'; iframe.allow = 'clipboard-write';
iframe.referrerPolicy = 'no-referrer'; iframe.referrerPolicy = 'no-referrer';
iframe.style.width = '100%'; iframe.style.width = '100%';
@ -143,6 +159,16 @@ export class BotWidgetEmbed {
private readonly disposables: Array<() => void> = []; 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 // Dedup events that have already been forwarded to the widget. Encrypted DMs
// hit feedEvent twice in succession (once from RoomEvent.Timeline with the // hit feedEvent twice in succession (once from RoomEvent.Timeline with the
// ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure // ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure
@ -175,6 +201,59 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev); 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) { public constructor(private readonly options: BotWidgetEmbedOptions) {
const { mx, room, preset, container, theme, language } = options; const { mx, room, preset, container, theme, language } = options;
const widget = createBotWidget(preset, room, mx, theme, language); const widget = createBotWidget(preset, room, mx, theme, language);
@ -184,6 +263,13 @@ export class BotWidgetEmbed {
clientLanguage: language, clientLanguage: language,
widgetRoomId: room.roomId, 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: // Strict ordering — DO NOT reorder:
// 1. Build iframe with NO src. // 1. Build iframe with NO src.
@ -236,6 +322,7 @@ export class BotWidgetEmbed {
room.on(RoomEvent.Timeline, this.onTimelineEvent); room.on(RoomEvent.Timeline, this.onTimelineEvent);
mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
mx.on(RoomStateEvent.Events, this.onStateUpdate); mx.on(RoomStateEvent.Events, this.onStateUpdate);
window.addEventListener('message', this.onWidgetMessage);
this.disposables.push( this.disposables.push(
() => api.off('ready', onReady), () => api.off('ready', onReady),
@ -243,7 +330,8 @@ export class BotWidgetEmbed {
() => iframe.removeEventListener('error', onIframeError), () => iframe.removeEventListener('error', onIframeError),
() => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent), () => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent),
() => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted), () => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted),
() => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate) () => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate),
() => window.removeEventListener('message', this.onWidgetMessage)
); );
} }