From a286d1e2c6faed0f52e940509abc68ee835b47d9 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 10 Jun 2026 22:24:23 +0300 Subject: [PATCH] feat(telegram): rewrite the widget onto the bridge provisioning API with OpenID auth, a contacts picker, and MSC4039 avatars --- apps/widget-telegram/README.md | 145 +- apps/widget-telegram/src/App.tsx | 2045 ++++------------- apps/widget-telegram/src/avatars.ts | 121 + apps/widget-telegram/src/bootstrap.ts | 39 +- .../src/bridge-protocol/dialects/go_v2604.ts | 507 ---- .../src/bridge-protocol/parser.ts | 17 - .../src/bridge-protocol/types.ts | 83 - apps/widget-telegram/src/contacts.tsx | 409 ++++ apps/widget-telegram/src/errors.ts | 47 + apps/widget-telegram/src/i18n/en.ts | 173 +- apps/widget-telegram/src/i18n/ru.ts | 165 +- apps/widget-telegram/src/login.tsx | 748 ++++++ apps/widget-telegram/src/main.tsx | 44 +- apps/widget-telegram/src/provisioning.ts | 347 +++ apps/widget-telegram/src/state.ts | 1057 --------- apps/widget-telegram/src/styles.css | 545 ++++- apps/widget-telegram/src/ui.tsx | 220 ++ apps/widget-telegram/src/widget-api.ts | 265 +-- config.json | 4 +- src/app/features/bots/BotWidgetDriver.ts | 57 +- src/app/features/bots/BotWidgetEmbed.ts | 6 + src/app/features/bots/BotWidgetMount.tsx | 168 +- src/app/features/bots/catalog.ts | 53 +- src/app/hooks/useClientConfig.ts | 7 + 24 files changed, 3517 insertions(+), 3755 deletions(-) create mode 100644 apps/widget-telegram/src/avatars.ts delete mode 100644 apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts delete mode 100644 apps/widget-telegram/src/bridge-protocol/parser.ts delete mode 100644 apps/widget-telegram/src/bridge-protocol/types.ts create mode 100644 apps/widget-telegram/src/contacts.tsx create mode 100644 apps/widget-telegram/src/errors.ts create mode 100644 apps/widget-telegram/src/login.tsx create mode 100644 apps/widget-telegram/src/provisioning.ts delete mode 100644 apps/widget-telegram/src/state.ts create mode 100644 apps/widget-telegram/src/ui.tsx diff --git a/apps/widget-telegram/README.md b/apps/widget-telegram/README.md index d40c28e0..d342772f 100644 --- a/apps/widget-telegram/README.md +++ b/apps/widget-telegram/README.md @@ -1,23 +1,45 @@ # @vojo/widget-telegram Vojo Telegram bridge management widget — mounts inside `/bots/telegram` -in the Vojo client. See [`docs/plans/bots_tab.md`](../../docs/plans/bots_tab.md) -Phase 3 for product context and the matrix-widget-api contract. +in the Vojo client. -This is **not** a Telegram client. It's a small panel that drives the -mautrix-telegram bridge bot (`@telegrambot:vojo.chat`) by sending text -commands in the control DM and rendering the bot's text replies. M11 -ships only the bootstrap + a `ping` button to verify the host handshake. +This is **not** a Telegram client. It's a control panel for the +mautrix-telegram bridge that talks to the bridge's **provisioning HTTP +API** (bridgev2 `/_matrix/provision/v3/*`, exposed by Caddy at +`https://vojo.chat/_provision/telegram`). It signs the user in +(phone+code+2FA or QR), shows the linked account, lists Telegram +contacts, resolves @usernames / +phones, and creates DM portals on +demand. + +Auth: the widget requests MSC1960 OpenID credentials from the host +(`get_openid` over the widget API; granted by `BotWidgetDriver.askOpenID` +when config.json opts the bot into the `vojo.openid` capability) and +sends them to the bridge as `Authorization: Bearer openid:`. The +OpenID token only proves identity — it is not a Matrix access token. + +There is no bot text-command transport and no reply parsing: the legacy +`!tg`-command dialect (`bridge-protocol/`) was deleted when the bridge +API became reachable. The bot control DM still exists (BotShell needs a +room and the «Show chat» fallback), the widget just doesn't read or +write it — it requests **zero** MSC2762 capabilities. ## Layout ``` src/ -├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts) -├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK) -├── App.tsx UI: bootstrap card, action buttons, transcript pane -├── main.tsx Entry: init bootstrap, render App or diagnostic -└── styles.css Theme-aware CSS variables +├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts) +├── widget-api.ts Inline matrix-widget-api postMessage transport: handshake, +│ theme, MSC1960 get_openid, io.vojo.bot-widget verbs +├── provisioning.ts Typed client for the bridgev2 provisioning API + identifier +│ helpers (wire contract documented in the file header) +├── errors.ts Bridge/Telegram error → localized copy mapping +├── login.tsx Login flow over the v3 step machine + forms (phone/code/ +│ password/QR with long-poll rotation) +├── contacts.tsx Contacts list, search-as-filter, resolve-probe, create DM +├── App.tsx Shell: boot/disconnected/connected phases, tabs, account +├── ui.tsx Icons, initials avatar, command cards, notices +├── main.tsx Entry: init bootstrap, render App or diagnostic +└── styles.css Theme-aware CSS (Dawn palette, light remap via data-theme) ``` ## Local development @@ -28,6 +50,10 @@ server overlays it on top of `/config.json` responses (see `serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the overlay entirely. +Note the overlay merges bot entries **shallowly** — your local +`experience` object replaces the base one wholesale, so it must carry +`provisioningUrl` and `capabilities` too: + ```bash # one-time: install widget deps cd apps/widget-telegram && npm install @@ -40,7 +66,9 @@ cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON' "id": "telegram", "experience": { "type": "matrix-widget", - "url": "http://localhost:8081/" + "url": "http://localhost:8081/", + "provisioningUrl": "https://vojo.chat/_provision/telegram", + "capabilities": ["vojo.openid"] } } ] @@ -48,10 +76,6 @@ cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON' JSON ``` -The overlay merges `bots[]` by `id`, so just `{ id, experience }` is -enough — base bot's `mxid` and `name` are preserved. Top-level fields -not present in `config.local.json` are inherited from `config.json`. - Run both servers: ```bash @@ -63,7 +87,10 @@ cd /home/ubuntu/projects/vojo/cinny && npm start ``` Open `http://localhost:8080/bots/telegram`. Iframe loads cross-origin -from the widget dev server, HMR works, no proxy. +from the widget dev server, HMR works, no proxy. The provisioning calls +go straight to the prod bridge API (CORS `*` + per-request bearer auth), +acting on whatever account you're signed in with — same trust model as +the dev client talking to the prod homeserver. `http://localhost:*` URLs are accepted by the host's URL validator only in dev builds (`import.meta.env.DEV` branch in @@ -72,10 +99,6 @@ via Vite's dead-code elimination, AND production-only enforces an origin allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even if config.json is poisoned. -Deploy is unchanged. `config.local.json` is gitignored, never shipped. -You don't need to revert anything before `Deploy to vojo.chat` — there -is nothing in tracked files that points at localhost. - Standalone preview of the widget bundle (no host, useful for visual iteration): @@ -94,56 +117,38 @@ npm run build Outputs to `apps/widget-telegram/dist/`. Deploy by rsyncing `dist/*` into `~/vojo/widgets/telegram/` on the production host (Caddy serves -this via the `widgets.vojo.chat` block). One parent `~/vojo/widgets/` -directory hosts every bot widget — adding a second one is `mkdir -~/vojo/widgets//` plus a Caddy block, no docker-compose edit. +this via the `widgets.vojo.chat` block). -## Hosting (server-side, runbook) +## Server-side requirements + +1. The widget static files at `widgets.vojo.chat/telegram/` (Caddy + `handle_path /telegram/*` block — see git history of this README for + the full runbook). +2. The bridge provisioning API exposed at the URL configured in + config.json `experience.provisioningUrl`. Caddy block inside the + `vojo.chat` site: -1. DNS: `widgets.vojo.chat` A/AAAA → server. Verify with `dig`. -2. `~/vojo/docker-compose.yml` — Caddy `volumes:` adds (one parent mount, - future widgets reuse it): - ```yaml - - ./widgets:/var/www/widgets ``` -3. `~/vojo/caddy/Caddyfile` — append: - ``` - widgets.vojo.chat { - encode zstd gzip - - header { - Content-Security-Policy "frame-ancestors https://vojo.chat https://localhost" - X-Content-Type-Options "nosniff" - Referrer-Policy "no-referrer" - Cache-Control "no-cache, no-store, must-revalidate" - -Server - } - - handle_path /telegram/* { - root * /var/www/widgets/telegram - try_files {path} /index.html - file_server - } - - handle { - respond "Not Found" 404 - } + handle /_provision/telegram/* { + uri replace /_provision/telegram /_matrix/provision + reverse_proxy telegram-bridge:29317 # host:port from appservice.address } ``` -4. `mkdir -p ~/vojo/widgets/telegram` (placeholder so cert provisioning - has something to serve), then `docker compose up -d caddy` to apply. -5. Verify directly: `curl -I https://widgets.vojo.chat/telegram/index.html` - should return 200 and the `Content-Security-Policy` header. + + Path-scoped on purpose: the same bridge listener serves the appservice + transaction endpoints (`/_matrix/app/*`), which must stay internal. +3. `provisioning.shared_secret` in the bridge config must NOT be + `disable` (a ≥16-char secret enables the API; the widget never sees + the secret — it authenticates with per-user OpenID tokens). ## Updating the production /config.json -Once the widget is live at `https://widgets.vojo.chat/telegram/index.html`, -add to the host repo's `config.json`: - ```json "experience": { "type": "matrix-widget", - "url": "https://widgets.vojo.chat/telegram/index.html" + "url": "https://widgets.vojo.chat/telegram/index.html", + "provisioningUrl": "https://vojo.chat/_provision/telegram", + "capabilities": ["vojo.openid"] } ``` @@ -160,17 +165,11 @@ Without this, Android's WebView hijacks the cross-origin iframe URL into ## Capability contract -The widget requests EXACTLY this set (matches the host's -`BotWidgetDriver.getBotWidgetCapabilities`): +The widget requests **no** MSC2762 capabilities — the handshake replies +with an empty list. The only privileged surfaces are: -``` -org.matrix.msc2762.timeline: -org.matrix.msc2762.send.event:m.room.message#m.text -org.matrix.msc2762.receive.event:m.room.message#m.text -org.matrix.msc2762.receive.event:m.room.message#m.notice -org.matrix.msc2762.receive.state_event:m.room.member -``` - -Anything else is silently dropped by the host. To extend the surface, -update `BotWidgetDriver.ts` upstream — that requires a security review -per Phase 2 plan §M9. +- MSC1960 `get_openid` — granted by `BotWidgetDriver.askOpenID` iff + config.json declares `"capabilities": ["vojo.openid"]` for this bot; +- `io.vojo.bot-widget` side-channel verbs `open-external-url` and + `open-matrix-to` (origin-pinned and validated host-side in + `BotWidgetEmbed.onWidgetMessage`). diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 7a8e6a27..cc4245a8 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -1,880 +1,73 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; -import type { Dispatch } from 'preact/hooks'; -import type { ComponentChildren } from 'preact'; -import qrcodeGenerator from 'qrcode-generator'; -// `/min` metadata (~15 KB gzip) covers all country calling codes + length -// validation. Sufficient for «is this a plausible phone number?» — the -// bridge does the authoritative validation server-side. Avoid `/max` -// (~60 KB) since the widget is a separate Preact bundle and ships into -// the bot iframe on cold start. -import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min'; +// Widget shell. Three surfaces driven by GET /v3/whoami: +// +// boot → handshake + first whoami round-trip +// disconnected → login action cards + the active login-flow form +// connected → the contacts list (single main surface); profile and +// session actions live in the Account modal behind the +// icon button next to the status pill +// +// The bridge is driven exclusively over its provisioning HTTP API +// (provisioning.ts) authenticated with MSC1960 OpenID credentials from the +// host. There is no transcript pane and no bot text-command parsing — every +// action reports its status inline, next to the control that fired it. +// +// Visual canon: «Боты» mockup at docs/design/new-direct-messages-design/ +// project/stream-v2-dawn.jsx (BotsDesktop) — Dawn palette, fleet-violet +// accent, friendly product cards, no terminal styling. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import type { WidgetBootstrap } from './bootstrap'; -import { WidgetApi, type RoomEvent } from './widget-api'; +import { WidgetApi } from './widget-api'; +import { ProvisioningClient, type WhoamiLogin } from './provisioning'; +import { describeApiError } from './errors'; import { createT, type T } from './i18n'; -import { parseEvent } from './bridge-protocol/parser'; +import { CodeForm, PasswordForm, PhoneForm, QrPanel, useLoginFlow } from './login'; +import { Contacts } from './contacts'; +import { useFirstAvatar } from './avatars'; import { - hydrateFromTimeline, - initialLoginState, - loginReducer, - type HydrateInput, - type LoginAction, - type LoginErrorFlag, - type LoginState, -} from './state'; - -// Visual canon: «Боты · Commands IDE» mockup at -// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx -// function BotsDesktop (lines 651-707) — Dawn palette, fleet-violet accent, -// 56px square avatar, monospace handles. M12 adds three inline form cards -// (.auth-card) and a destructive logout card (.command-card.danger) inside -// the same vocabulary. - -type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error'; - -type TranscriptLine = { - id: string; - ts: number; - kind: TranscriptKind; - text: string; -}; + Avatar, + BackIcon, + CommandCard, + InfoIcon, + LogoutIcon, + Notice, + PhoneIcon, + QrIcon, + RefreshIcon, + Spinner, + StatusPill, + UserIcon, +} from './ui'; type Props = { bootstrap: WidgetBootstrap; - // The WidgetApi is constructed in main.tsx synchronously, BEFORE React's - // first render, so its message listener is attached before the host's - // ClientWidgetApi sends its capabilities request on iframe `load`. - // Constructing inside a useEffect here would race with the cached-bundle - // case (second mount after «Show chat» → «Show widget») and silently - // miss the handshake. Keep the construction at module-load. + // Constructed in main.tsx BEFORE the first render so its postMessage + // listener is attached before the host's ClientWidgetApi fires the + // capabilities request on iframe `load`. api: WidgetApi; }; -const TRANSCRIPT_MAX = 200; +type Phase = + | { kind: 'boot' } + | { kind: 'error'; message: string } + | { kind: 'disconnected' } + | { kind: 'connected'; login: WhoamiLogin }; -// 8s — see plan: hint window between submit and bot reply. SMS-side latency -// is owned by the user (they can see the form is open and waiting). -const STILL_WAITING_DELAY_MS = 8_000; +type NoticeState = { tone: 'error' | 'info'; text: string } | null; -// 30s countdown shown on the code form. The phone-form hint warns about a -// 30-second SMS window; this is the visible counterpart on the form that -// actually waits for the SMS to arrive. -const CODE_COUNTDOWN_SECONDS = 30; - -// Inline SVG refresh icon — replaces the unicode «⟳» glyph that read more -// like ASCII art than UI in the previous draft. Stroke-only, so it picks -// up `currentColor` from the parent button and matches the Dawn palette -// without a colored asset. -const RefreshIcon = () => ( - -); - -// Inline SVG info icon — leads the AboutCard. Shared shape with the -// Discord widget so «How the bot works» reads the same across the -// catalog. WhatsApp uses a triangle warning glyph instead because its -// About modal also carries a Meta-ToS risk disclosure. -const InfoIcon = () => ( - -); - -// Smartphone outline — leads the «Войти в Telegram» card. Same shape -// as the WhatsApp pairing-code card; both flows ask for a phone-side -// number, so the icon language stays consistent. -const PhoneIcon = () => ( - -); - -// Three QR finder squares + a sprinkle of module dots — leads every -// QR-login card. The finder pattern is the strongest «this is QR» -// visual cue; no need to draw a full code. -const QrIcon = () => ( - -); - -// Eye + eye-with-slash for the password reveal toggle. SVG paths -// copied verbatim from folds `Icons.Eye(false)` / `Icons.EyeBlind(false)` -// — the unfilled variants Vojo's main auth uses via -// `src/app/components/password-input/PasswordInput.tsx`. Importing folds -// into the widget bundle would pull the whole component library, so we -// inline the geometry. ViewBox 24×24 + `fill="currentColor"` matches -// the folds Icon component output bit-for-bit; the only divergence vs -// the host is the wrapper (folds `IconButton` vs our plain ` - - -
{t('auth-card.phone.hint')}
- {showInvalidHint && !error ? ( -
{t('auth-card.phone.invalid')}
- ) : null} - {error ? ( -
- {localizeError(error, t)} -
- ) : null} - {submitting && stillWaiting ? ( -
{t('auth-card.waiting-hint')}
- ) : null} - - ); -}; - -// Tick-down hook for the SMS countdown. Starts on mount, decrements every -// second until 0, holds. Returns a single number; the caller decides what -// to render at 0 (we flip to a «didn't arrive — try again» hint). -const useCountdown = (initialSeconds: number): number => { - const [remaining, setRemaining] = useState(initialSeconds); - useEffect(() => { - if (remaining <= 0) return undefined; - const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000); - return () => window.clearTimeout(timer); - }, [remaining]); - return remaining; -}; - -const CodeForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { - const [value, setValue] = useState(''); - const [submitting, setSubmitting] = useState(false); - const inputRef = useRef(null); - const stillWaiting = useStillWaitingHint([submitting]); - // SMS countdown ticks from mount of the code form (i.e. when the bot - // confirmed it sent a code). Independent of `submitting` — the timer - // is about SMS arrival, not bot-reply latency. - const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS); - const error = state.kind === 'awaiting_code' ? state.lastError : undefined; - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const onSubmit = async (event: Event) => { - event.preventDefault(); - const trimmed = value.trim(); - if (!trimmed || submitting) return; - setSubmitting(true); - // Clear input value AND stale error optimistically — user is making a - // fresh attempt; the old red-state would conflict visually with the new - // transcript-side outcome (which may itself be a different error). - setValue(''); - dispatch({ kind: 'submit_code' }); - try { - await send(trimmed, 'code'); - } catch { - /* transcript carries the diagnostic; form stays open for retry */ - } finally { - setSubmitting(false); - } - }; - - const tone = error ? errorTone(error) : undefined; - - return ( -
-
{t('auth-card.code.title')}
- -
- setValue((e.currentTarget as HTMLInputElement).value)} - disabled={submitting} - /> - - -
- {error ? ( -
- {localizeError(error, t)} -
- ) : null} - {/* - * SMS countdown is suppressed while the form is submitting — the - * bot-latency hint takes over once the user has typed and clicked. - * Both rendering simultaneously stacks two distinct waits (SMS - * arrival vs bot reply) on the same form and reads as clutter. - */} - {!submitting && - (countdownSeconds > 0 ? ( -
- {t('auth-card.code.countdown', { seconds: String(countdownSeconds) })} -
- ) : ( -
{t('auth-card.code.countdown-done')}
- ))} - {submitting && stillWaiting ? ( -
{t('auth-card.waiting-hint')}
- ) : null} -
- ); -}; - -const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { - const [value, setValue] = useState(''); - const [reveal, setReveal] = useState(false); - const [submitting, setSubmitting] = useState(false); - const inputRef = useRef(null); - const stillWaiting = useStillWaitingHint([submitting]); - const error = state.kind === 'awaiting_password' ? state.lastError : undefined; - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const onSubmit = async (event: Event) => { - event.preventDefault(); - if (!value || submitting) return; - setSubmitting(true); - // Capture the plaintext into a local and drop it from component state - // BEFORE awaiting the send. If the send throws or the form unmounts - // mid-flight, no plaintext lingers in React state or the DOM input — - // server-side redaction (bridgev2/commands/login.go:226) only fires - // after the message arrives, and we don't want a window where the - // password sits accessible to Preact Devtools / source maps. - const password = value; - setValue(''); - dispatch({ kind: 'submit_password' }); - try { - await send(password, 'password'); - } catch { - /* transcript carries the diagnostic; user retypes */ - } finally { - setSubmitting(false); - } - }; - - const tone = error ? errorTone(error) : undefined; - - return ( -
-
{t('auth-card.password.title')}
-
{t('auth-card.password.hint')}
- -
-
- ` against that - // size-derived minimum, so the input refused to shrink and - // pushed past the `.auth-card` border on narrow viewports. - // Setting `size=1` drops the intrinsic floor so `flex: 1` - // + `min-width: 0` actually shrink the box to the slot. - size={1} - value={value} - onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)} - disabled={submitting} - /> - -
- - -
- {error ? ( -
- {localizeError(error, t)} -
- ) : null} - {submitting && stillWaiting ? ( -
{t('auth-card.waiting-hint')}
- ) : null} -
- ); -}; - -// -------------------------------------------------------------------------- -// QR panel -// -------------------------------------------------------------------------- - -// Telegram's QR-auth tokens rotate roughly every 30 s (anti-replay per -// MTProto spec); the bridge edits the same `m.image` event with each -// rotation. The bridgev2 server-side LoginTimeout is 10 minutes — we -// surface that as a soft countdown, NOT a hard kill: when it expires we -// switch the panel into a «попробуйте снова» recovery hint so the user -// knows the bridge will no longer accept this particular QR even if -// rotations are still landing on the wire. -const QR_TIMEOUT_MS = 10 * 60 * 1000; - -// Error-correction level M is the right balance for short URLs: more -// resilient to camera glare / dead pixels than L, smaller modules than Q, -// matches what Telegram Desktop renders for its own QR-login screen. -// `qrcode-generator` accepts a typeNumber=0 «auto» argument which picks -// the smallest version that fits the payload — for a `tg://login?token=…` -// URL this typically lands at version 5–8. -const buildQrModules = (data: string): boolean[][] | null => { - if (!data) return null; - try { - const qr = qrcodeGenerator(0, 'M'); - qr.addData(data); - qr.make(); - const count = qr.getModuleCount(); - const matrix: boolean[][] = []; - for (let r = 0; r < count; r += 1) { - const row: boolean[] = []; - for (let c = 0; c < count; c += 1) { - row.push(qr.isDark(r, c)); - } - matrix.push(row); - } - return matrix; - } catch { - return null; - } -}; - -// Render the QR matrix as elements inside an SVG. We deliberately -// avoid `dangerouslySetInnerHTML` / `qr.createSvgTag()` and any external -// QR-rendering service: the `tg://login?token=...` URL IS the login -// secret, so it must never leave the iframe and must never reach a -// stringified-HTML path that bypasses Preact's escaping. -type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string }; -const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => { - const count = matrix.length; - const margin = 4; - const totalUnits = count + margin * 2; - const cellPx = pixelSize / totalUnits; - // Pre-build the rect list to keep the JSX readable. Each is one - // dark module; the white background is painted by the parent
's - // `background: #fff`. - const rects: ComponentChildren[] = []; - for (let r = 0; r < count; r += 1) { - for (let c = 0; c < count; c += 1) { - if (!matrix[r][c]) continue; - rects.push( - - ); - } - } - return ( - - {rects} - - ); -}; - -type QrPanelProps = { - state: { - kind: 'awaiting_qr_scan'; - tgUrl: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - }; - t: T; - sendCancel: () => Promise; -}; - -const QrPanel = ({ state, t, sendCancel }: QrPanelProps) => { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); - - const matrix = useMemo(() => buildQrModules(state.tgUrl), [state.tgUrl]); - const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0; - const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000)); - const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0; - - return ( -
-
{t('auth-card.qr.title')}
-
{t('auth-card.qr.hint')}
-
- {matrix ? ( - // The aria-label describes the PURPOSE of the QR, not its - // contents — the URL itself is the login secret and must not - // be read out loud / serialised to AT-tree text content. - - ) : ( -
- - {t('auth-card.qr.preparing')} -
- )} -
- {!expired ? ( -
- {t('auth-card.qr.countdown', { - minutes: String(Math.floor(remainingSeconds / 60)), - seconds: String(remainingSeconds % 60).padStart(2, '0'), - })} -
- ) : ( -
{t('auth-card.qr.expired')}
- )} -
    -
  1. {t('auth-card.qr.step-1')}
  2. -
  3. {t('auth-card.qr.step-2')}
  4. -
  5. {t('auth-card.qr.step-3')}
  6. -
- {state.lastError ? ( - // Soft errors that land on the QR-scan state — primarily - // `login_in_progress` (bridge already has another flow open) and - // `start_failed` (Telegram-side preflight hiccup). Without this - // line the panel renders a perpetual «Готовим QR-код…» - // placeholder with no explanation and the user can't tell - // whether to wait or hit Cancel. -
- {localizeError(state.lastError, t)} -
- ) : null} -
- -
-
- ); -}; - -// -------------------------------------------------------------------------- -// About card + modal -// -------------------------------------------------------------------------- - -type AboutCardProps = { - t: T; - onOpen: () => void; -}; - -// Click-target sibling of the login/logout cards. Lives in the widget -// (not in the host hero) so it sits visually adjacent to the action it -// explains — a user evaluating «should I sign in?» reads the about copy -// next to the button itself, not in a separate header chrome. -const AboutCard = ({ t, onOpen }: AboutCardProps) => ( - -); - -type AboutModalProps = { - t: T; - onClose: () => void; -}; +type AboutModalProps = { t: T; onClose: () => void }; const AboutModal = ({ t, onClose }: AboutModalProps) => { - // ESC closes — matches the standard «modal as transient overlay» pattern - // and keeps a fast escape hatch for keyboard users without focus-trap - // machinery (the widget is a small surface; trap-style focus management - // would be heavier than the panel deserves). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); @@ -890,8 +83,6 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => { aria-modal="true" aria-label={t('about.title')} onClick={(e) => { - // Backdrop click closes; a click that started inside the panel - // bubbles up here too — guard by comparing currentTarget. if (e.target === e.currentTarget) onClose(); }} > @@ -929,23 +120,15 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => { ); }; -// -------------------------------------------------------------------------- -// Logout card with confirm-in-place -// -------------------------------------------------------------------------- +// --- Logout card (confirm-in-place) --------------------------------------------- -type LogoutCardProps = { - loginId: string | undefined; - t: T; - onConfirm: (loginId: string) => Promise; -}; +type LogoutCardProps = { t: T; onConfirm: () => Promise }; -const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => { +const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => { const [confirming, setConfirming] = useState(false); const [submitting, setSubmitting] = useState(false); - // Belt-and-suspenders against double-submit. `disabled={submitting}` on the - // button covers 99% of cases, but there's a microtask window between click - // and React/Preact rendering the disabled state where a fast second click - // could fire — the ref closes that window synchronously. + // Sync double-submit guard for the microtask window between click and the + // disabled state landing in the DOM. const inFlight = useRef(false); if (confirming) { @@ -956,13 +139,13 @@ const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => { + danger + /> ); }; -// -------------------------------------------------------------------------- -// Main App -// -------------------------------------------------------------------------- +// --- Connected view ------------------------------------------------------------- +// The account surface IS the main connected screen (profile card + action +// cards, telegram-bot style). «Контакты» is one of the cards — it swaps the +// surface for the contacts list (search + rows) with a back affordance. + +type ConnectedViewProps = { + login: WhoamiLogin; + client: ProvisioningClient; + api: WidgetApi; + t: T; + onNotice: (notice: NoticeState) => void; + onLogout: () => Promise; + onOpenAbout: () => void; +}; + +const ConnectedView = ({ + login, + client, + api, + t, + onNotice, + onLogout, + onOpenAbout, +}: ConnectedViewProps) => { + const [view, setView] = useState<'account' | 'contacts'>('account'); + // Header-row refresh control for the contacts list: bumping the token + // re-fetches inside , which reports its in-flight state back so + // the button can spin/disable. + const [contactsReloadToken, setContactsReloadToken] = useState(0); + const [contactsLoading, setContactsLoading] = useState(false); + + const profile = login.profile ?? {}; + const stateEvent = login.state?.state_event ?? ''; + // Transitional states (connecting/backfilling/starting) are healthy — only + // genuinely broken sessions (BAD_CREDENTIALS, UNKNOWN_ERROR, …) get the + // warning strip with the suggest-relink copy. + const stateBad = !['', 'CONNECTED', 'CONNECTING', 'BACKFILLING', 'STARTING'].includes(stateEvent); + + // Own avatar comes from three sources of decreasing trust, and the first + // one can be EMPTY or point at dead media (whoami's profile.avatar is only + // filled once the bridge has met our own ghost), so the loader walks the + // candidate list and shows the first that actually downloads: + // 1. whoami profile.avatar; + // 2. our own entry in the Telegram address book (reported up by Contacts + // once the list loads — the same pipeline as every other avatar); + // 3. a one-shot resolve of our own Telegram id. + const [selfContactMxc, setSelfContactMxc] = useState(undefined); + const [selfResolvedMxc, setSelfResolvedMxc] = useState(undefined); + useEffect(() => { + let alive = true; + client + .resolveIdentifier(login.id) + .then((self) => { + if (alive && self?.avatar_url) setSelfResolvedMxc(self.avatar_url); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [client, login.id]); + const avatarSrc = useFirstAvatar(api, [profile.avatar, selfContactMxc, selfResolvedMxc]); + + // Telegram's RemoteName prefers the username over the display name + // (connector userToRemoteProfile), so for the headline we go the other way + // round — the human name first — and drop the @username line when it would + // just repeat the headline. + const headline = profile.name || login.name || loginHandle(login); + const username = profile.username ?? ''; + const showUsername = + username !== '' && headline.replace(/^@/, '').toLowerCase() !== username.toLowerCase(); + + return ( +
+
+ + {t('status.connected-as', { handle: loginHandle(login) })} + + {view === 'contacts' ? ( + <> + + + + ) : null} +
+ + {stateBad ? ( + + {t('account.state-bad', { + reason: login.state?.message || login.state?.error || stateEvent, + })} + + ) : null} + + {view === 'account' ? ( +
+ {/* The profile card lives INSIDE the grid so it gets the same + * width/height budget as the action cards (a full-bleed block + * reads as a banner on wide web viewports). */} + + } + name={t('card.contacts.name')} + desc={t('card.contacts.desc')} + onClick={() => setView('contacts')} + /> + } + name={t('card.about.name')} + desc={t('card.about.desc')} + onClick={onOpenAbout} + /> + {/* Destructive action sits last, after every constructive one. */} + +
+ ) : ( + setSelfContactMxc(mxc)} + reloadToken={contactsReloadToken} + onLoadingChange={setContactsLoading} + onOpenRoom={(roomId) => api.openMatrixRoom(roomId)} + onError={(message) => onNotice({ tone: 'error', text: message })} + /> + )} +
+ ); +}; + +// --- Main (provisioning configured) ---------------------------------------------- + +type MainProps = { + api: WidgetApi; + client: ProvisioningClient; + t: T; + handshakeOk: boolean; +}; + +const Main = ({ api, client, t, handshakeOk }: MainProps) => { + const [phase, setPhase] = useState({ kind: 'boot' }); + const [notice, setNotice] = useState(null); + const [aboutOpen, setAboutOpen] = useState(false); + const phaseRef = useRef(phase); + phaseRef.current = phase; + + const refresh = useCallback( + async (opts: { quiet?: boolean } = {}): Promise => { + try { + const whoami = await client.whoami(); + const login = whoami.logins?.[0]; + setPhase(login ? { kind: 'connected', login } : { kind: 'disconnected' }); + } catch (err) { + // Quiet refreshes (visibility change, post-action recheck) keep the + // last good surface; only the boot path degrades to the error screen. + if (opts.quiet && phaseRef.current.kind !== 'boot') return; + setPhase({ kind: 'error', message: describeApiError(err, t) }); + } + }, + [client, t] + ); + + const flow = useLoginFlow(client, t, { + onComplete: () => { + setNotice({ tone: 'info', text: t('notice.login-success') }); + void refresh(); + }, + onError: (message) => setNotice({ tone: 'error', text: message }), + }); + const flowUiKind = flow.ui.kind; + + // First whoami once the widget-api handshake completes. + useEffect(() => { + if (handshakeOk) void refresh(); + }, [handshakeOk, refresh]); + + // Re-check on tab return — bridge state can change from another device + // (e.g. session revoked in Telegram's Settings → Devices). Never during an + // active login flow: stomping the form mid-entry would lose user input. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState !== 'visible') return; + if (flowUiKind !== 'idle') return; + if (phaseRef.current.kind === 'boot') return; + void refresh({ quiet: true }); + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [refresh, flowUiKind]); + + // Success notices fade on their own; errors stay until dismissed. + useEffect(() => { + if (!notice || notice.tone !== 'info') return undefined; + const timer = window.setTimeout(() => setNotice(null), 6000); + return () => window.clearTimeout(timer); + }, [notice]); + + const logout = useCallback(async () => { + if (phaseRef.current.kind !== 'connected') return; + try { + await client.logout(phaseRef.current.login.id); + setNotice({ tone: 'info', text: t('notice.logged-out') }); + } catch (err) { + setNotice({ tone: 'error', text: describeApiError(err, t) }); + } + await refresh(); + }, [client, refresh, t]); + + const [refreshingCard, setRefreshingCard] = useState(false); + const refreshCard = useCallback(async () => { + if (refreshingCard) return; + setRefreshingCard(true); + const start = Date.now(); + await refresh(); + const elapsed = Date.now() - start; + window.setTimeout(() => setRefreshingCard(false), Math.max(0, 500 - elapsed)); + }, [refresh, refreshingCard]); + + const renderDisconnected = () => { + if (flow.ui.kind === 'form') { + const FormComponent = + flow.ui.form === 'phone' ? PhoneForm : flow.ui.form === 'code' ? CodeForm : PasswordForm; + return ( +
+ +
+ ); + } + if (flow.ui.kind === 'qr') { + return ( +
+ +
+ ); + } + if (flow.ui.kind === 'starting') { + return ( +
+
+ + {flow.ui.flow === 'qr' ? t('auth-card.qr.preparing') : t('status.checking')} + + +
+
+ ); + } + return ( +
+ {t('status.disconnected')} +
+ } + name={t('card.login.name')} + desc={t('card.login.desc')} + onClick={() => flow.start('phone')} + /> + } + name={t('card.login-qr.name')} + desc={t('card.login-qr.desc')} + onClick={() => flow.start('qr')} + /> + } + name={t('card.refresh.name')} + desc={refreshingCard ? t('card.refresh.in-flight') : t('card.refresh.desc')} + onClick={() => void refreshCard()} + disabled={refreshingCard} + spinning={refreshingCard} + /> + } + name={t('card.about.name')} + desc={t('card.about.desc')} + onClick={() => setAboutOpen(true)} + /> +
+
+ ); + }; + + return ( +
+ {notice ? ( +
+ setNotice(null)} + > + {notice.text} + +
+ ) : null} + + {phase.kind === 'boot' ? ( +
+ + {handshakeOk ? t('status.checking') : t('boot.connecting')} + +
+ ) : null} + + {phase.kind === 'error' ? ( +
+
+ {phase.message} + +
+
+ ) : null} + + {phase.kind === 'disconnected' ? renderDisconnected() : null} + + {phase.kind === 'connected' ? ( + setAboutOpen(true)} + /> + ) : null} + + {aboutOpen ? setAboutOpen(false)} /> : null} +
+ ); +}; + +// --- App root -------------------------------------------------------------------- export function App({ bootstrap, api }: Props) { const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); - const [transcript, setTranscript] = useState([]); const [handshakeOk, setHandshakeOk] = useState(false); - const [aboutOpen, setAboutOpen] = useState(false); - // True while a `list-logins` probe is in flight from a refresh-card click. - // Drives the button's `disabled` + spinner state so the user gets explicit - // «I'm working» feedback during the round-trip — the previous design left - // the click unacknowledged (the only signal was a transcript line below - // the fold), and the bare card surface combined with various WebView - // hover/focus quirks read as «button stuck on grey». - const [refreshing, setRefreshing] = useState(false); - const apiRef = useRef(api); - const seenEventIds = useRef(new Set()); - const [state, dispatch] = useReducer(loginReducer, initialLoginState); - - // stateRef mirrors the latest reducer state so async callbacks (live - // event listeners attached once at mount) can read current state - // without their stale closure capturing the initial `unknown` snapshot. - // Used by the transcript diag gate for `qr_redacted` (only show - // «QR использован» when the redaction targets the active QR). - const stateRef = useRef(state); - useEffect(() => { - stateRef.current = state; - }, [state]); - const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]); useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); - // Capture-phase click interceptor for `` — - // inside Capacitor's Android WebView, cross-origin iframes silently - // drop those clicks (the WebView has no multi-window concept and - // the host's setupExternalLinkHandler can't see them across the - // origin boundary). We preventDefault and ask the host to open the - // URL via Browser.open / window.open. Modifier-clicks pass through - // untouched so users can still Ctrl/Cmd-click for new-tab on web. + useEffect(() => { + api.on('ready', () => setHandshakeOk(true)); + api.on('themeChange', (name) => setTheme(name)); + return () => api.dispose(); + // `api` is a module-level singleton owned by main.tsx; the effect + // intentionally runs once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Capture-phase interceptor for `` — cross-origin + // iframes inside Capacitor's Android WebView silently drop those clicks, + // so we route them through the host's openExternalUrl side-channel. + // Modifier-clicks pass through for desktop new-tab behavior. useEffect(() => { const onClick = (e: MouseEvent) => { const anchor = (e.target as HTMLElement | null)?.closest?.( @@ -1064,656 +583,26 @@ export function App({ bootstrap, api }: Props) { return () => document.removeEventListener('click', onClick, true); }, [api]); - const append = useCallback((line: Omit) => { - setTranscript((prev) => { - const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; - return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next; - }); - }, []); - - // Newest-at-top ordering: pin the scroll to the TOP whenever a new line - // lands. The data structure stays chronologically appended (oldest first - // in the array — required by the slice(-TRANSCRIPT_MAX) cap and by the - // hydrate replay's seenEventIds dedupe); only the render reverses it. - // Users who scroll down to read older context get snapped back to top on - // the next message — acceptable here because the transcript is a passive - // log, not a primary reading surface (forms and status pills are above). - const transcriptRef = useRef(null); - useEffect(() => { - const el = transcriptRef.current; - if (!el) return; - el.scrollTop = 0; - }, [transcript.length]); - - // App-level cooldown state for phone-form Send Code button. Lifted out of - // PhoneForm so it survives the form's unmount on Cancel + remount on a - // fresh login click — otherwise the user could spam Send→Cancel→repeat - // and saturate Telegram's SMS rate limit before bridgev2 reports back. - const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null); - - // Clear the cooldown when bridge replies that our phone was malformed — - // `invalid_value` on the awaiting_phone form means the value was - // rejected at validate-time, BEFORE bridgev2 dispatched any Telegram - // API call. No SMS was attempted, so the 60s lock would just punish a - // typo. The cooldown stays in place for `submit_failed` (Telegram-side - // FloodWait/banned/etc — those cases SMS may have been attempted). - useEffect(() => { - if ( - state.kind === 'awaiting_phone' && - state.lastError?.kind === 'invalid_value' && - phoneCooldownEnd !== null - ) { - setPhoneCooldownEnd(null); - } - }, [state, phoneCooldownEnd]); - - // Subscribe to widget-api events for capability handshake completion, - // live events, and theme updates. The `api` itself is constructed in - // main.tsx BEFORE React's first render so its postMessage listener is - // already attached — this effect only wires React state to the api's - // event surface. WidgetApi.on('ready', ...) self-replays if the - // handshake already finished by the time we attach (cached-bundle - // remount: bundle parses near-instantly and the host's capabilities - // request can resolve before this useEffect runs). - useEffect(() => { - // Guard for the async hydrate IIFE inside the ready handler — flipped - // by the cleanup function. We don't return a Promise from the effect - // (Preact's useEffect expects a void/cleanup return), so the async - // work runs in a self-invoking wrapper that bails on unmount. - let disposed = false; - - api.on('ready', () => { - setHandshakeOk(true); - append({ kind: 'diag', text: t('diag.ready') }); - append({ kind: 'diag', text: t('diag.checking-status') }); - - void (async () => { - // M12.5 timeline-resume: scan the recent room history BEFORE firing - // list-logins. bridgev2's list-logins doesn't surface pending - // CommandState — if the user reloads mid-«Please enter your Code», - // a list-logins probe replies «You're not logged in» and the UI - // helpfully offers a fresh login button, losing the SMS code. - // Reading the timeline lets us restore the form the user actually - // had open. M13 extends this to also pick up active QR-login - // flows: m.image events carry the rotating tg://login URL, and - // m.room.redaction events signal a successful scan. All three - // streams are merged chronologically and fed to the same hydrate - // reducer. - let hydrated = false; - try { - // Promise.allSettled (not all): if a single readTimeline rejects - // — driver capability denied, network blip, transport timeout — - // we still want to feed whatever we got into the hydrate - // reducer. With Promise.all, one failed read takes down the - // notice-only path that worked since M12.5 (phone/code/password - // resume) just because the QR-extension read couldn't satisfy. - const settled = await Promise.allSettled([ - api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }), - // QR images: bridgev2 edits the original event with each token - // rotation (~30 s per Telegram MTProto), so a full 10-minute - // login attempt produces up to ~20 events. We pull 50 to give - // headroom for slower rotations and out-of-order delivery, - // and so the redaction's `redacts: $original` target is in - // our scan window even if the user reloads near the timeout - // boundary. - api.readTimeline({ limit: 50, type: 'm.room.message', msgtype: 'm.image' }), - api.readTimeline({ limit: 10, type: 'm.room.redaction' }), - ]); - if (disposed) return; - const pickValue = (s: PromiseSettledResult): RoomEvent[] => - s.status === 'fulfilled' ? s.value : []; - const notices = pickValue(settled[0]); - const qrImages = pickValue(settled[1]); - const redactions = pickValue(settled[2]); - - // Driver returns events newest-first; reverse each stream and - // merge by origin_server_ts ascending so the hydrate reducer - // walks past→present in true chronological order across event - // types (a redaction landing between two image edits must be - // applied at the right point in the chain). - const fromBot = (events: RoomEvent[]) => - events.filter((e) => e.sender === bootstrap.botMxid); - // Sort by origin_server_ts ascending, tie-breaking on event_id - // lexicographically. Without the tie-break, equal-timestamp - // events from different streams (notice vs image vs redaction) - // could process in nondeterministic order — e.g. a redaction - // landing AFTER the image edit it cleans up versus before - // would change which qr_displayed our hydrate latches onto. - // event_id is opaque but stable, which is enough for - // determinism even if not semantically meaningful. - const merged = [...fromBot(notices), ...fromBot(qrImages), ...fromBot(redactions)].sort( - (a, b) => { - const tsDiff = a.origin_server_ts - b.origin_server_ts; - if (tsDiff !== 0) return tsDiff; - return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0; - } - ); - - const inputs: HydrateInput[] = merged.map((e) => ({ - ev: parseEvent(e), - ts: e.origin_server_ts, - })); - const restored = hydrateFromTimeline(inputs); - - if (restored) { - // Conservative transcript replay: bot m.notice lines + a - // history marker. m.image events are replaced with a generic - // «QR обновлён» diag — never replay the raw `tg://login?token=...` - // body, that would persist the login token in DOM history past - // the bridge's redaction. Redactions get a «QR consumed» diag. - // User m.text is intentionally NOT replayed — bridgev2 does - // not redact 2FA codes server-side, and replaying user echoes - // would surface a code from history that the original submit - // had masked locally. - // - // Dedupe via seenEventIds: a live event for the same notice/ - // image/redaction may already have arrived during the - // readTimeline await. Skipping seen ids in this loop avoids - // the duplicate line, AND the .add side-effect simultaneously - // pre-seeds the set so any post-hydrate live replay of these - // same events is suppressed too. - let appendedAnyHistory = false; - // Track which QR event ids we've seen during this scan so a - // `qr_redacted` diag fires only for redactions that match a - // QR we actually replayed. Without this, a stale redaction - // from a previous flow (or an unrelated bot redaction) would - // print a misleading «QR использован» line in history. - const seenQrIds = new Set(); - for (const e of merged) { - if (seenEventIds.current.has(e.event_id)) continue; - seenEventIds.current.add(e.event_id); - const parsed = parseEvent(e); - // Only flip `appendedAnyHistory` when an actual line is - // emitted. Otherwise the trailing «─── история ───» - // marker would float above an empty pane (e.g. timeline - // had only m.image events that parsed to `unknown`, - // skipped silently below). - if (parsed.kind === 'qr_displayed') { - seenQrIds.add(parsed.eventId); - if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId); - append({ kind: 'diag', text: t('diag.qr-issued') }); - appendedAnyHistory = true; - } else if (parsed.kind === 'qr_redacted') { - if (seenQrIds.has(parsed.redactsEventId)) { - append({ kind: 'diag', text: t('diag.qr-consumed') }); - appendedAnyHistory = true; - } - } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { - // m.text / m.notice — body is safe to replay verbatim. - // m.image without tg URL falls into `unknown` upstream - // and we silently skip it (no diag, no body) — there's - // no scenario where the bridge sends arbitrary images - // to the control DM and an opaque «image» line would - // just be noise. - append({ kind: 'from-bot', text: `← ${e.content.body ?? ''}` }); - appendedAnyHistory = true; - } - } - if (appendedAnyHistory) { - append({ kind: 'diag', text: t('diag.history-marker') }); - } - - dispatch({ kind: 'hydrate', state: restored }); - hydrated = true; - } - } catch { - // Driver / capability / network failure — fall through to live - // list-logins probe. We surface a soft diag line so a persistent - // failure is visible in logs without blocking the user. - if (!disposed) { - append({ kind: 'diag', text: t('diag.history-unavailable') }); - } - } - - if (disposed) return; - if (!hydrated) { - api.sendCommand('list-logins').catch((err) => { - if (disposed) return; - append({ - kind: 'error', - text: t('diag.send-failed', { message: (err as Error).message }), - }); - }); - } - })(); - }); - - api.on('themeChange', (name) => setTheme(name)); - - api.on('liveEvent', (ev: RoomEvent) => { - if (seenEventIds.current.has(ev.event_id)) return; - seenEventIds.current.add(ev.event_id); - // Defense-in-depth sender filter. Phase 1's strict 1:1 invariant - // already guarantees the only senders in this DM are the user and - // the bot, but pinning to bootstrap.botMxid covers both: - // (a) skip our own outbound echoes (those are appended - // optimistically with masking applied at click time — the live - // event would render the unmasked password back into the - // transcript), and - // (b) ignore any third-party noise that somehow slips past the - // 1:1 invariant. - if (ev.sender !== bootstrap.botMxid) return; - - // Bot reply → LoginEvent → state machine. parseEvent dispatches by - // event type — m.text/m.notice keep the existing body-regex flow, - // m.image becomes qr_displayed, m.room.redaction becomes qr_redacted. - const event = parseEvent(ev); - - // Transcript routing is GATED on the parser's verdict, not on the - // raw event type. That way: - // * an unrelated bot-side redaction (e.g. of an old text reply) - // doesn't print «QR использован» — only a redaction parsed as - // `qr_redacted` against the CURRENTLY-ACTIVE QR does (checked - // against stateRef.current); - // * an `m.image` whose body lacks `tg://login?` doesn't print - // «QR обновлён» — the diag now corresponds to a real QR - // payload our reducer is going to act on; - // * the body of an `m.image` is NEVER appended verbatim — the - // body IS the `tg://login?token=...` secret. - if (event.kind === 'qr_displayed') { - append({ kind: 'diag', text: t('diag.qr-issued') }); - } else if (event.kind === 'qr_redacted') { - const liveState = stateRef.current; - if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) { - append({ kind: 'diag', text: t('diag.qr-consumed') }); - } - } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { - const body = ev.content.body ?? ''; - append({ kind: 'from-bot', text: `← ${body}` }); - } - - dispatch({ kind: 'event', event }); - - // After a fresh login_success the bridge doesn't send the loginId in - // the success line. Re-run list-logins so the reducer's `connected` - // state can pick up the loginId for the future logout call. - if (event.kind === 'login_success') { - api.sendCommand('list-logins').catch(() => { - /* surface in diag is overkill; the connected hero still works - without a loginId until the user clicks logout */ - }); - } - }); - - append({ kind: 'diag', text: t('diag.connecting') }); - - return () => { - disposed = true; - // App-level unmount tears down the iframe window entirely (host - // detaches the iframe DOM node), so dispose just clears pending - // request promises. Don't null `apiRef.current` — `api` is a - // module-level singleton owned by main.tsx, not by this component. - api.dispose(); - }; - // `api`, `bootstrap`, `t`, and `append` are stable for the App's - // lifetime; the effect intentionally runs once at mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Outbound command + transcript echo. `mask` decides what shows up in the - // local transcript; the wire payload always carries the real value. - // Errors are appended to the transcript AND rethrown — callers decide - // whether to roll back optimistic state transitions. - const send = useCallback( - async (body: string, mask: 'phone' | 'code' | 'password' = 'phone'): Promise => { - const api = apiRef.current; - if (!api) throw new Error('widget transport not ready'); - append({ kind: 'from-user', text: `→ ${redactOutbound(body, mask)}` }); - try { - await api.sendCommand(body); - } catch (err) { - append({ - kind: 'error', - text: t('diag.send-failed', { message: (err as Error).message }), - }); - throw err; - } - }, - [append, t] + const client = useMemo( + () => + bootstrap.provisioningUrl + ? new ProvisioningClient(bootstrap.provisioningUrl, bootstrap.userId, () => + api.getOpenIdCredentials() + ) + : null, + [api, bootstrap.provisioningUrl, bootstrap.userId] ); - const sendBare = useCallback( - async (command: string): Promise => { - const api = apiRef.current; - if (!api) throw new Error('widget transport not ready'); - append({ kind: 'from-user', text: `→ ${command}` }); - try { - await api.sendCommand(command); - } catch (err) { - append({ - kind: 'error', - text: t('diag.send-failed', { message: (err as Error).message }), - }); - throw err; - } - }, - [append, t] - ); - - // Optimistic transition + rollback on send failure. Cancel is panic-UX — - // we want immediate disconnected state. If the send fails, the bot just - // doesn't know we cancelled; its CommandState times out eventually. - const sendCancel = useCallback(async () => { - dispatch({ kind: 'cancel_pending' }); - try { - await sendBare('cancel'); - } catch { - /* already showing disconnected; transcript carries the failure */ - } - }, [sendBare]); - - // In-flight guard against double-tap. The button is on the disconnected - // screen which unmounts as soon as state advances, BUT a rapid second - // click can fire in the microtask window between dispatch and the next - // React commit (especially on Android WebView, where a tap-rebound can - // synthesise a second click). For phone login, a duplicate `!tg login - // phone` would burn an extra SMS; for QR it's just a redundant - // round-trip but the bridge replies `login_in_progress` and we'd surface - // a confusing yellow warning. Sync ref closes the gap. - const loginInFlight = useRef(false); - - // Optimistic awaiting_phone + rollback to disconnected on send failure. - // Without rollback the user would see the phone form open with no command - // ever delivered to the bot. - const onClickLogin = useCallback(async () => { - if (loginInFlight.current) return; - loginInFlight.current = true; - dispatch({ kind: 'start_login' }); - try { - await sendBare('login phone'); - } catch { - dispatch({ kind: 'cancel_pending' }); - } finally { - loginInFlight.current = false; - } - }, [sendBare]); - - // Same optimistic pattern for the QR-login flow. The reducer's - // start_qr_login transitions us into a placeholder awaiting_qr_scan - // (tgUrl='', qrEventId=''); the live `qr_displayed` event shortly after - // overwrites both with the real bridge payload. - const onClickLoginQr = useCallback(async () => { - if (loginInFlight.current) return; - loginInFlight.current = true; - dispatch({ kind: 'start_qr_login' }); - try { - await sendBare('login qr'); - } catch { - dispatch({ kind: 'cancel_pending' }); - } finally { - loginInFlight.current = false; - } - }, [sendBare]); - - const onClickRefresh = useCallback(async () => { - if (refreshing) return; - setRefreshing(true); - const start = Date.now(); - try { - await sendBare('list-logins'); - } catch { - /* transcript carries the failure */ - } - // 500 ms minimum visible loading state. `sendBare` typically resolves - // in <100 ms when the transport is healthy — without a min-hold the - // browser may not even paint a single frame of the disabled state, so - // the click goes visually unacknowledged. The transcript line is the - // only other signal and it sits below the fold on phone viewports. - const elapsed = Date.now() - start; - if (elapsed < 500) { - await new Promise((resolve) => { - window.setTimeout(resolve, 500 - elapsed); - }); - } - setRefreshing(false); - }, [refreshing, sendBare]); - - // Optimistic logging_out + recovery on send failure: refire list-logins - // so the reducer recalibrates from the bridge's truth instead of leaving - // the UI stuck in logging_out forever. - const onConfirmLogout = useCallback( - async (loginId: string) => { - dispatch({ kind: 'request_logout', loginId }); - try { - await sendBare(`logout ${loginId}`); - } catch { - sendBare('list-logins').catch(() => { - /* nothing more we can do — user can hit refresh */ - }); - } - }, - [sendBare] - ); - - const formProps: FormProps = { - state, - t, - dispatch, - send, - sendCancel, - phoneCooldownEnd, - setPhoneCooldownEnd, - }; - - return ( -
- {/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no - * longer renders an avatar/name/handle/description block — the host - * panel above the iframe carries that information plus the - * three-dots menu (show-chat / mark-read / notifications / - * leave-room). «О боте» lives HERE in the widget body so it sits - * adjacent to the login/logout actions it explains. */} - - {handshakeOk && state.kind === 'unknown' ? ( -
-
- - - {t('status.unknown')} - - {/* Recovery affordance — without this the user stares at the - * «Проверка статуса…» pill forever if the initial - * list-logins reply was dropped on the wire. */} - -
-
- ) : null} - - {handshakeOk && state.kind === 'disconnected' ? ( -
- {/* Status pill describes state («Telegram не привязан»), command - * cards below carry the actions. Earlier copy made the pill an - * imperative («Войдите в Telegram»), which duplicated the login - * card's title sitting directly beneath it. */} - - - {t('status.disconnected')} - -
- - {/* QR-login peer to phone-login. Telegram-side flow is the - * cleanest path on a phone — no SMS, no code-typing — so we - * surface it as a primary action rather than burying it in a - * sub-menu. Same card vocabulary as login-by-phone. */} - - {/* Refresh as a peer card to login (same size + style). The - * `refreshing` class + disabled attribute drive the in-flight - * spinner state — disabled gates :hover/:focus via :not(:disabled) - * and so neutralises any WebView quirks that previously made the - * click read as «button stuck on grey» after tap. The spinner - * targets the leading icon slot (the chevron is back to `›` - * for parity with every other card). */} - - setAboutOpen(true)} /> -
-
- ) : null} - - {state.kind === 'awaiting_phone' ? ( -
- -
- ) : null} - {state.kind === 'awaiting_code' ? ( -
- -
- ) : null} - {state.kind === 'awaiting_password' ? ( -
- -
- ) : null} - {state.kind === 'awaiting_qr_scan' ? ( -
- -
- ) : null} - {state.kind === 'qr_verifying' ? ( -
-
- - - {t('status.qr-verifying')} - - {/* If the bridge stalls between «scan accepted» and the - * follow-up (twofa_required / login_success), there's no - * other affordance to dig out — the QR panel is gone, no - * form is open. The recovery button refires `list-logins`, - * which routes the reducer back to disconnected/connected - * via not_logged_in / logins_listed. Without it, a network - * split mid-handshake would freeze the user on the «check» - * pill until they reload the whole page. */} - -
-
- ) : null} - - {state.kind === 'logging_out' ? ( -
-
- - - {t('status.logging-out')} - - {/* If the bot's `Logged out` reply never arrives, refresh - * fires `list-logins` and the reducer recalibrates from - * bridge truth (gets routed back to disconnected/connected - * via logins_listed/not_logged_in). Without this there's no - * way out of `logging_out` short of a page reload. */} - -
-
- ) : null} - - {state.kind === 'connected' ? ( -
- {state.loginId ? ( - - - {state.handle - ? t('status.connected-as', { handle: state.handle }) - : t('status.connected')} - - ) : ( -
- - - {state.handle - ? t('status.connected-as', { handle: state.handle }) - : t('status.connected')} - - {/* Visible refresh when we don't yet have a loginId. The - * post-login_success list-logins is the normal source — - * if it dropped, the logout card stays disabled forever - * with only an invisible tooltip. Surface the recovery - * action explicitly so the user isn't trapped. */} - -
- )} -
- - setAboutOpen(true)} /> -
-
- ) : null} - - {aboutOpen ? setAboutOpen(false)} /> : null} - -
-
- {transcript.length === 0 ? ( -
{/* placeholder kept blank intentionally */}
- ) : ( - // Render newest-first by reversing a shallow copy. The source - // array is kept chronological — see the comment on the - // scroll-to-top effect above. - transcript - .slice() - .reverse() - .map((line) => ( -
- {formatTime(line.ts)} - - {line.kind === 'from-bot' ? renderBody(line.text) : line.text} - -
- )) - )} + if (!client) { + return ( +
+
+ {t('config.missing.title')} + {t('config.missing.body')}
-
-
- ); +
+ ); + } + + return
; } diff --git a/apps/widget-telegram/src/avatars.ts b/apps/widget-telegram/src/avatars.ts new file mode 100644 index 00000000..d58a59de --- /dev/null +++ b/apps/widget-telegram/src/avatars.ts @@ -0,0 +1,121 @@ +// Avatar loader on top of the host's MSC4039 download_file. Module-level +// cache (mxc → objectURL promise) so a contact list re-render or tab switch +// never re-downloads, plus a small concurrency gate so opening a 200-contact +// list doesn't fire 200 parallel postMessage round-trips at once. +// +// Object URLs are kept for the iframe's lifetime — the widget document dies +// with the bot page, and the blobs are 96px thumbnails, so there's nothing +// worth revoking eagerly. + +import { useEffect, useState } from 'preact/hooks'; +import type { WidgetApi } from './widget-api'; + +const cache = new Map>(); + +const MAX_CONCURRENT_DOWNLOADS = 4; +let active = 0; +const waiters: Array<() => void> = []; + +const acquireSlot = async (): Promise => { + if (active >= MAX_CONCURRENT_DOWNLOADS) { + await new Promise((resolve) => { + waiters.push(resolve); + }); + } + active += 1; +}; + +const releaseSlot = (): void => { + active -= 1; + waiters.shift()?.(); +}; + +// Failed downloads stay cached only briefly: long enough that one list +// render can't hammer a dead media endpoint, short enough that a transient +// network blip doesn't pin initials for the rest of the session. +const FAILURE_RETRY_MS = 60_000; + +const loadAvatar = async (api: WidgetApi, mxc: string): Promise => { + await acquireSlot(); + try { + const blob = await api.downloadFile(mxc); + return URL.createObjectURL(blob); + } catch { + // Missing media / capability denied / network — initials fallback now, + // retry possible after the negative-cache window. + window.setTimeout(() => cache.delete(mxc), FAILURE_RETRY_MS); + return null; + } finally { + releaseSlot(); + } +}; + +const resolveAvatar = (api: WidgetApi, mxc: string): Promise => { + let promise = cache.get(mxc); + if (!promise) { + promise = loadAvatar(api, mxc); + cache.set(mxc, promise); + } + return promise; +}; + +/** Resolve an mxc avatar URI to a local object URL (null while loading or on + * failure — callers render the initials fallback in both cases). */ +export const useMxcAvatar = (api: WidgetApi, mxc: string | undefined): string | null => { + const [url, setUrl] = useState(null); + + useEffect(() => { + if (!mxc || !mxc.startsWith('mxc://')) { + setUrl(null); + return undefined; + } + let alive = true; + setUrl(null); + resolveAvatar(api, mxc).then((resolved) => { + if (alive) setUrl(resolved); + }); + return () => { + alive = false; + }; + }, [api, mxc]); + + return url; +}; + +/** Like useMxcAvatar, but walks a PRIORITY LIST of candidate mxc URIs and + * returns the first one that actually downloads. Built for the own-profile + * avatar, where the primary source (whoami profile.avatar) can be empty OR + * point at media that no longer resolves — a dead first candidate must not + * mask a live second one. */ +export const useFirstAvatar = ( + api: WidgetApi, + candidates: Array +): string | null => { + const [url, setUrl] = useState(null); + const key = candidates.filter(Boolean).join('|'); + + useEffect(() => { + let alive = true; + setUrl(null); + const list = key === '' ? [] : key.split('|'); + void (async () => { + for (const mxc of list) { + if (!mxc.startsWith('mxc://')) continue; + // Sequential on purpose: candidates are ordered by trustworthiness + // and the list is ≤3 entries, all cached after the first pass. + // eslint-disable-next-line no-await-in-loop + const resolved = await resolveAvatar(api, mxc); + if (!alive) return; + if (resolved) { + setUrl(resolved); + return; + } + } + })(); + return () => { + alive = false; + }; + }, [api, key]); + + return url; +}; diff --git a/apps/widget-telegram/src/bootstrap.ts b/apps/widget-telegram/src/bootstrap.ts index 14c25e32..56f80e93 100644 --- a/apps/widget-telegram/src/bootstrap.ts +++ b/apps/widget-telegram/src/bootstrap.ts @@ -1,4 +1,4 @@ -// Parse the URL params the Phase 2 bot widget host appends when loading +// Parse the URL params the bot widget host appends when loading // experience.url. Source of truth on the host side: // src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl). // Keep this in sync if the host adds params. @@ -11,12 +11,12 @@ export type WidgetBootstrap = { userId: string; botId: string; botMxid: string; - /** Bridge command prefix (e.g. `!tg`). Always non-empty — the host - * validator (catalog.ts) defaults missing values to `!tg` and rejects - * malformed overrides. The widget prepends ` ` to every - * outbound command and form-field value (bridgev2/queue.go:118 strips - * exactly `prefix+" "`). */ - commandPrefix: string; + /** Base URL of the bridge provisioning HTTP API (bridgev2 + * `/_matrix/provision` mount behind the reverse proxy), e.g. + * `https://vojo.chat/_provision/telegram`. Empty string when the host + * config hasn't exposed it — the App renders a config-required notice + * instead of booting the transport. */ + provisioningUrl: string; theme: 'light' | 'dark'; clientLanguage: string; }; @@ -25,7 +25,7 @@ export type BootstrapResult = | { ok: true; bootstrap: WidgetBootstrap } | { ok: false; missing: string[] }; -const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const; +const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const; export const readBootstrap = (search: string): BootstrapResult => { const params = new URLSearchParams(search); @@ -44,6 +44,27 @@ export const readBootstrap = (search: string): BootstrapResult => { return { ok: false, missing: ['parentUrl'] }; } + // The host validator (catalog.ts normalizeProvisioningUrl) already + // enforces https + no embedded credentials; re-parse defensively anyway + // because this is the widget's fetch target. Malformed → '' → the App + // shows the config-required notice rather than fetching a garbage URL. + let provisioningUrl = ''; + const rawProvisioning = get('provisioningUrl').trim(); + if (rawProvisioning) { + try { + const parsed = new URL(rawProvisioning); + if ( + !parsed.username && + !parsed.password && + (parsed.protocol === 'https:' || (import.meta.env.DEV && parsed.protocol === 'http:')) + ) { + provisioningUrl = parsed.toString().replace(/\/+$/, ''); + } + } catch { + /* keep '' */ + } + } + const themeRaw = get('theme'); const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light'; @@ -57,7 +78,7 @@ export const readBootstrap = (search: string): BootstrapResult => { userId: get('userId'), botId: get('botId'), botMxid: get('botMxid'), - commandPrefix: get('commandPrefix'), + provisioningUrl, theme, clientLanguage: get('clientLanguage'), }, diff --git a/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts b/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts deleted file mode 100644 index ea5095ba..00000000 --- a/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts +++ /dev/null @@ -1,507 +0,0 @@ -// Dialect: mautrix-telegram Go rewrite v0.2604.0 + mautrix/go bridgev2. -// Generated against tag v0.2604.0 (commit b9f09628, 26 Apr 2026). -// -// Each regex is paired with its upstream source; if bridgev2 wording drifts -// in a future patch, replace this file with a sibling go_v2607.ts (or -// whatever) and switch the import in ../parser.ts. -// -// Body encoding note: bridgev2 routes replies through `format.RenderMarkdown` -// (bridgev2/commands/event.go:58) which sets `formatted_body` to HTML and -// `body` to the markdown source. 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. - -import type { LoginEvent, ListedLogin, ParsableEvent } from '../types'; - -// --- Regex table ---------------------------------------------------------- - -// list-logins, empty: bridgev2/commands/login.go:564 → `You're not logged in` -// Note: NO trailing period. The Python v0.15.3 dialect ended with one — this -// is a stable structural fingerprint between dialects. -const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i; - -// list-logins, non-empty: bridgev2/user.go:185-190 ships a leading `\n` due -// to a `make([]string, N) + append` bug. Each row is -// `* `` () - ```. -// Tolerate both leading-whitespace and a future fix that removes the bug. -// -// Name capture uses greedy `(.+)` (not `[^)]*`) because Telegram display -// names commonly contain literal `)` — e.g. «Example (Work)», «Имя -// (Личный)». The trailing anchor `\)\s+-\s+``` forces the regex -// engine to backtrack to the LAST `)` before ` - `<…>``, so nested -// parens parse correctly. -const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm; - -// Phone prompt — bridgev2/commands/login.go:207 + connector loginphone.go:74. -// Composed: `Please enter your \n`. Phone step -// has no Instructions, so this is the only reply. -const PHONE_PROMPT_RE = /^please enter your phone number\b/i; - -// Code prompt — bridgev2/commands/login.go:207 + connector loginphone.go:98. -// Same composition; sent on initial code request. -const CODE_PROMPT_RE = /^please enter your code\b/i; - -// 2fa Instructions — connector login.go:170. First of TWO replies; the second -// is `Please enter your Password` which falls into PASSWORD_REPROMPT_RE. -const TWOFA_INSTRUCTIONS_RE = /^you have two-factor authentication enabled\.?$/i; - -// Password re-prompt — bridgev2/commands/login.go:207. Emitted both after -// the 2fa instructions and after a wrong-password re-prompt. -const PASSWORD_REPROMPT_RE = /^please enter your password\s*$/i; - -// Code incorrect Instructions — connector loginphone.go:107. First of two. -const CODE_INCORRECT_RE = /^incorrect code\.?$/i; - -// Password incorrect Instructions — connector login.go:183. First of two. -const PASSWORD_INCORRECT_RE = /^incorrect password,/i; - -// Login success — connector login.go:290. Format string is -// `Successfully logged in as %s (\`%d\`)` — the numeric id is wrapped in -// markdown backticks which survive into `body`. Capture both for UI use. -const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(.+?)\s+\(`?(\d+)`?\)\.?$/i; - -// Logout — bridgev2/commands/login.go:591 → `Logged out` (no period). -const LOGOUT_OK_RE = /^logged out\.?$/i; - -// Cancel — bridgev2/commands/processor.go:198 / 200. Action for our -// flow is always `Login` (set by userInputLoginCommandState at login.go:218). -const CANCEL_OK_RE = /^login cancelled\.?$/i; -const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i; - -// Login already in progress — bridgev2/commands/login.go:83. -const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i; - -// Max logins — bridgev2/commands/login.go:74-79. 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:587 (logout) and 68 -// (relogin). Single backtick-wrapped id capture. -const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i; - -// Flow selector errors — bridgev2/commands/login.go:107 / 98. -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:163. -const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i; - -// Generic error traps. Each anchors on a distinct prefix, so order between -// them is incidental — kept ordered for readability. -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; -// bridgev2/commands/login.go:366 — `Login failed: %v` from -// doLoginDisplayAndWait Wait error path. Captures both the 10-minute -// LoginTimeout (`login process timed out`) and post-cancel -// (`context canceled`) cases. -const LOGIN_FAILED_RE = /^login failed:\s*(.*)$/i; - -// --- Parser --------------------------------------------------------------- - -const trimReplyBody = (raw: string): string => { - // Bridge sometimes emits a leading `\n` (login-list bug, user.go:185). - // Trim outer whitespace before matching to keep regexes anchored on `^`. - return raw.trim(); -}; - -const parseLoginList = (body: string): ListedLogin[] => { - const logins: ListedLogin[] = []; - // matchAll requires the global flag — preserve LOGIN_LIST_ROW_RE's lastIndex - // by rebuilding it for each call (RegExp instances are stateful with /g). - 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; -}; - -export const parseGoV2604 = (rawBody: string): LoginEvent => { - const body = trimReplyBody(rawBody); - if (body.length === 0) return { kind: 'unknown' }; - - // Order: highly-specific terminal/transitional matches first, generic - // error traps last. The login-list parser comes early because its anchor - // (` * `` `) wouldn't false-match anything else, and the alternative - // — `not_logged_in` — covers the empty-list case explicitly. - - 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(), - numericId: successMatch[2], - }; - } - - if (TWOFA_INSTRUCTIONS_RE.test(body)) return { kind: 'twofa_required' }; - if (CODE_INCORRECT_RE.test(body)) return { kind: 'invalid_code' }; - if (PASSWORD_INCORRECT_RE.test(body)) return { kind: 'wrong_password' }; - - if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' }; - if (CODE_PROMPT_RE.test(body)) return { kind: 'awaiting_code' }; - if (PASSWORD_REPROMPT_RE.test(body)) return { kind: 'awaiting_password' }; - - 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() }; - - // 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 ---------------------------------------------------- -// -// `parseEventGoV2604` dispatches on `event.type` and routes: -// -// * `m.room.redaction` → `qr_redacted`. We don't need to verify the redacted -// target here; the state machine pairs the redaction's `redacts` against -// the active QR event id and decides whether it's a meaningful signal or -// an unrelated cleanup. -// -// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body -// contains a `tg://login?token=...` URL. The bridge sets that as the -// image's text body explicitly (mautrix/go bridgev2 commands/login.go -// sendQR sets `Body: qr` where `qr` is the token URL string). Anything -// else on m.image we don't recognise — fall through to `unknown` so the -// transcript still surfaces the line as a diag. -// -// * `m.room.message` + `msgtype=m.text|m.notice` → existing -// `parseGoV2604(body)` path. - -// Telegram QR-login URLs encode the token in `tg://login?token=...`. The -// bridge wraps it in markdown backticks inside `formatted_body` (we never -// see formatted_body — driver strips it), but `body` carries the raw URL -// per upstream `bridgev2/commands/login.go::sendQR` line 297 (`Body: qr`). -// The regex tolerates surrounding whitespace and a possible markdown -// backtick wrap on either side as defence-in-depth, even though the -// current wire shape doesn't include backticks in the plain body. -const TG_LOGIN_URL_RE = /tg:\/\/login\?[^\s`<>]+/i; - -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -export const parseEventGoV2604 = (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 URL 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 new - // token) 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; - - const match = body.match(TG_LOGIN_URL_RE); - if (!match) 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', - tgUrl: match[0], - 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 parseGoV2604(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]> = [ - ["You're not logged in", { kind: 'not_logged_in' }], - ["You're not logged in.", { kind: 'not_logged_in' }], - ['Please enter your Phone number\nInclude the country code with +', { kind: 'awaiting_phone' }], - [ - 'Please enter your Code\nThe code was sent to the Telegram app on your phone', - { kind: 'awaiting_code' }, - ], - ['You have two-factor authentication enabled.', { kind: 'twofa_required' }], - ['Please enter your Password', { kind: 'awaiting_password' }], - ['Incorrect code', { kind: 'invalid_code' }], - [ - "Incorrect password, please try again. Use the official Telegram app to reset your password if you've forgotten it.", - { kind: 'wrong_password' }, - ], - [ - 'Successfully logged in as @example (`123456789`)', - { kind: 'login_success', handle: '@example', numericId: '123456789' }, - ], - ['Logged out', { kind: 'logout_ok' }], - ['Login cancelled.', { kind: 'cancel_ok' }], - ['No ongoing command.', { kind: 'cancel_no_op' }], - [ - 'You already have an ongoing login. You can use `!tg 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 `!tg relogin` command.', - { kind: 'max_logins', limit: 1 }, - ], - ['Login `abc123` not found', { kind: 'login_not_found', loginId: 'abc123' }], - ['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }], - [ - 'Failed to submit input: rpc error: PHONE_NUMBER_BANNED (400)', - { kind: 'submit_failed', reason: 'rpc error: PHONE_NUMBER_BANNED (400)' }, - ], - [ - 'Failed to prepare login process: connector unavailable', - { kind: 'prepare_failed', reason: 'connector unavailable' }, - ], - [ - 'Failed to start login: telegram connect timeout', - { kind: 'start_failed', reason: 'telegram connect timeout' }, - ], - [ - 'Login failed: login process timed out', - { kind: 'login_failed', reason: 'login process timed out' }, - ], - [ - 'Login failed: context canceled', - { kind: 'login_failed', reason: 'context canceled' }, - ], - ['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }], - [ - 'Please specify a login flow, e.g. `login phone`.\n\n* `phone` - Login using your Telegram phone number\n* `qr` - Login by scanning a QR code from your phone\n* `bot` - Log in as a bot using the bot token provided by BotFather.\n', - { kind: 'flow_required' }, - ], - [ - 'Invalid login flow `wat`. Available options:\n\n* `phone` - …', - { kind: 'flow_invalid', flowId: 'wat' }, - ], - // Truly unrecognised body — the catch-all kind keeps the transcript - // usable even when bridgev2 wording drifts. - ['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }], - // Login list with the leading-newline bug present in v0.2604.0. - [ - '\n* `42` (Example User) - `CONNECTED`', - { - kind: 'logins_listed', - logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }], - }, - ], - // Same row without the bug — must keep matching after upstream fix. - [ - '* `42` (Example User) - `CONNECTED`', - { - kind: 'logins_listed', - logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }], - }, - ], - // Telegram display name with literal `)` inside — common case - // («Иван (Работа)», «Pavel (Beta)»). The greedy capture must - // backtrack to the LAST `)` before ` - ```, not stop at - // the first one. - [ - '* `42` (Example (Work)) - `CONNECTED`', - { - kind: 'logins_listed', - logins: [{ id: '42', name: 'Example (Work)', state: 'CONNECTED' }], - }, - ], - // Two rows in one reply (multi-login user) with leading-newline bug. - [ - '\n* `42` (Alice) - `CONNECTED`\n* `43` (Bob) - `CONNECTED`', - { - kind: 'logins_listed', - logins: [ - { id: '42', name: 'Alice', state: 'CONNECTED' }, - { id: '43', name: 'Bob', state: 'CONNECTED' }, - ], - }, - ], - ]; - - for (const [body, expected] of cases) { - const actual = parseGoV2604(body); - if (!sameEvent(actual, expected)) { - // Surface the diff loudly — dev overlay shows the throw, and the - // console error gives the inputs side-by-side for debugging. - // eslint-disable-next-line no-console - console.error('[go_v2604 sanity] mismatch', { body, actual, expected }); - throw new Error( - `go_v2604 parser sanity failed for body ${JSON.stringify(body)} — see console for diff` - ); - } - } - - // parseEventGoV2604 — 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]> = [ - [ - { - type: 'm.room.message', - event_id: '$qr1', - sender: '@telegrambot:vojo.chat', - content: { msgtype: 'm.image', body: 'tg://login?token=ABCDEF' }, - }, - { kind: 'qr_displayed', tgUrl: 'tg://login?token=ABCDEF', eventId: '$qr1' }, - ], - [ - // QR rotation edit — `m.relates_to.rel_type=m.replace` + new body - // inside `m.new_content.body`. The edited token must take precedence - // over the literal `body` (which the sender SDK may keep as the - // original to satisfy clients that don't render edits). - { - type: 'm.room.message', - event_id: '$qr2', - sender: '@telegrambot:vojo.chat', - content: { - msgtype: 'm.image', - body: 'tg://login?token=OLD', - 'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' }, - 'm.new_content': { msgtype: 'm.image', body: 'tg://login?token=ROTATED' }, - }, - }, - { - kind: 'qr_displayed', - tgUrl: 'tg://login?token=ROTATED', - eventId: '$qr2', - replacesEventId: '$qr1', - }, - ], - [ - // Bare m.image without a tg URL — the 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). - { - type: 'm.room.message', - event_id: '$rand', - sender: '@telegrambot:vojo.chat', - content: { msgtype: 'm.image', body: 'random non-tg image caption' }, - }, - { kind: 'unknown' }, - ], - [ - // Redaction — top-level `redacts` (host sanitizer mirrors at top-level). - { - type: 'm.room.redaction', - event_id: '$red1', - sender: '@telegrambot:vojo.chat', - content: { redacts: '$qr1' }, - redacts: '$qr1', - }, - { kind: 'qr_redacted', redactsEventId: '$qr1' }, - ], - [ - // Redaction missing target — the sanitizer should already reject this, - // but defence-in-depth: parser declines to invent a target. - { - type: 'm.room.redaction', - event_id: '$red2', - sender: '@telegrambot:vojo.chat', - content: {}, - }, - { kind: 'unknown' }, - ], - [ - // m.notice fall-through — preserves existing behaviour for plain - // text replies that already had body-side parser coverage. - { - type: 'm.room.message', - event_id: '$n1', - sender: '@telegrambot:vojo.chat', - content: { msgtype: 'm.notice', body: "You're not logged in" }, - }, - { kind: 'not_logged_in' }, - ], - ]; - - for (const [event, expected] of eventCases) { - const actual = parseEventGoV2604(event); - if (!sameEvent(actual, expected)) { - // eslint-disable-next-line no-console - console.error('[go_v2604 event sanity] mismatch', { event, actual, expected }); - throw new Error( - `go_v2604 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? ''}` - ); - } - } -} - -function sameEvent(a: LoginEvent, b: LoginEvent): boolean { - if (a.kind !== b.kind) return false; - // Shallow-compare the discriminated payload. Good enough for the small - // set of structures we emit; deeper equality would only matter if we - // returned arbitrary nested data. - return JSON.stringify(a) === JSON.stringify(b); -} diff --git a/apps/widget-telegram/src/bridge-protocol/parser.ts b/apps/widget-telegram/src/bridge-protocol/parser.ts deleted file mode 100644 index 7f3745a3..00000000 --- a/apps/widget-telegram/src/bridge-protocol/parser.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and -// the dialect handles the full event surface — m.text, m.notice, m.image -// (QR broadcasts), m.room.redaction (post-scan cleanup). M13 ships one -// dialect, `go_v2604`, for the operator's current bridge image. When -// bridgev2 strings drift 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 { parseEventGoV2604 } from './dialects/go_v2604'; - -export type { ParsableEvent }; - -export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventGoV2604(event); diff --git a/apps/widget-telegram/src/bridge-protocol/types.ts b/apps/widget-telegram/src/bridge-protocol/types.ts deleted file mode 100644 index 6d480176..00000000 --- a/apps/widget-telegram/src/bridge-protocol/types.ts +++ /dev/null @@ -1,83 +0,0 @@ -// LoginEvent — discriminated union the parser emits and the state machine -// consumes. One LoginEvent per inbound m.notice from the bridge bot. -// -// Multi-reply collapse rule: bridgev2 emits TWO replies for steps that have -// non-empty Instructions (2FA prompt, invalid code, wrong password) — the -// Instructions text first, then a `Please enter your ` re-prompt. -// The parser returns one event per notice; the state machine collapses the -// re-prompt into a no-op when the state already matches. -// -// Source-of-truth for every kind below is the Go-dialect wording table in -// docs/plans/bots_tab.md (Phase 3 → Research outcomes → R3 → Bridge response -// wording (Go v0.2604.0 snapshot)). - -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; -}; - -export type LoginEvent = - | { kind: 'logins_listed'; logins: ListedLogin[] } - | { kind: 'not_logged_in' } - | { kind: 'awaiting_phone' } - | { kind: 'awaiting_code' } - | { kind: 'awaiting_password' } - | { kind: 'twofa_required' } - | { kind: 'invalid_code' } - | { kind: 'wrong_password' } - | { kind: 'login_success'; handle: string; numericId: 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 } - // Catch-all for Telegram-side errors leaking through bridgev2's commands - // layer as `Failed to submit input: `. Surfaced to the user as a - // yellow inline warning with the verbatim Go error tail (no sub-code parse - // — gotd error format is unstable across patches). - | { kind: 'submit_failed'; reason?: string } - | { kind: 'prepare_failed'; reason?: string } - | { kind: 'start_failed'; reason?: string } - // bridgev2/commands/login.go:366 — `Login failed: ` after a - // display-and-wait branch returns an error from `login.Wait()`. Most - // common reasons: server-side `login process timed out` (10-min - // LoginTimeout in pkg/connector/loginqr.go:43) and `context canceled` - // when the user cancelled mid-QR (we've usually already moved to - // disconnected via cancel_pending in that case — see reducer). - | { kind: 'login_failed'; reason?: string } - // QR-login lifecycle (M13). The bridge ships `m.image` events whose - // `body` carries the raw `tg://login?token=...` URL; the widget renders - // the QR client-side from that URL and never touches the uploaded PNG. - // `replacesEventId` is set when this event is an `m.replace` edit of a - // prior QR event — the bridge rotates the token roughly every 30 s - // (anti-replay per Telegram MTProto spec) and edits the original event - // each time, so subsequent rotations carry the original event_id in - // `m.relates_to.event_id`. The widget treats that as «same QR-flow, - // updated payload» and just repaints; without it, every rotation would - // re-issue the «awaiting_qr_scan» state and reset transient form state. - | { kind: 'qr_displayed'; tgUrl: string; eventId: string; replacesEventId?: string } - // Bridge redacted the QR event after a successful scan. NOT terminal — - // a 2FA prompt or login success line typically follows; the state - // machine moves us into a `qr_verifying` interstitial until the next - // signal lands. - | { kind: 'qr_redacted'; redactsEventId: string } - | { kind: 'unknown' }; diff --git a/apps/widget-telegram/src/contacts.tsx b/apps/widget-telegram/src/contacts.tsx new file mode 100644 index 00000000..76647b35 --- /dev/null +++ b/apps/widget-telegram/src/contacts.tsx @@ -0,0 +1,409 @@ +// Contacts surface: the user's Telegram address book (GET /v3/contacts) with +// a single search field that doubles as «start a chat with anyone»: +// +// * typing letters filters the local list; +// * typing a +phone or @username shape surfaces an explicit «check on +// Telegram» action (GET /v3/resolve_identifier — never fired per +// keystroke: ContactsResolveUsername has no flood-wait retry in the +// connector, so probing is click-gated); +// * every row's action either opens the existing DM portal +// (`dm_room_mxid` from the API) or creates one (POST /v3/create_dm) +// and asks the host to navigate there. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { + ProvisioningClient, + contactHandles, + detectIdentifier, + type Contact, + type ProbeIdentifier, +} from './provisioning'; +import { describeApiError } from './errors'; +import { useMxcAvatar } from './avatars'; +import type { WidgetApi } from './widget-api'; +import { Avatar, RefreshIcon, SearchIcon, Spinner } from './ui'; +import type { T } from './i18n'; + +type ListState = + | { kind: 'loading' } + | { kind: 'error'; message: string } + | { kind: 'ready'; contacts: Contact[] }; + +type ProbeState = + | { status: 'checking'; identifier: ProbeIdentifier } + | { status: 'found'; identifier: ProbeIdentifier; contact: Contact } + | { status: 'not-found'; identifier: ProbeIdentifier }; + +type ContactsProps = { + client: ProvisioningClient; + /** Widget transport — used for MSC4039 avatar downloads. */ + api: WidgetApi; + t: T; + /** Telegram user id of the linked account (whoami login id). Your own + * address-book entry is hidden from the list, and probing your own + * number/username answers «это вы» instead of offering a chat. */ + selfId?: string; + /** Reports the avatar mxc of YOUR OWN address-book entry once the list + * loads — the most reliable own-avatar source (whoami's profile.avatar is + * empty until the bridge meets your ghost). */ + onSelfAvatar?: (mxc: string) => void; + /** Bump to re-fetch the list — the refresh control lives in the parent's + * header row, next to the status pill. */ + reloadToken?: number; + /** List-fetch in-flight signal for the parent's refresh button. */ + onLoadingChange?: (loading: boolean) => void; + /** Ask the host to navigate to a room (matrix.to side-channel verb). */ + onOpenRoom: (roomId: string) => void; + /** Surface a terminal action error in the global notice strip. */ + onError: (message: string) => void; +}; + +// --- Contact row ------------------------------------------------------------- + +type ContactRowProps = { + contact: Contact; + api: WidgetApi; + t: T; + busy: boolean; + /** Another row's action is in flight — this row's button is disabled. */ + anotherBusy: boolean; + onAction: () => void; +}; + +const ContactRow = ({ contact, api, t, busy, anotherBusy, onAction }: ContactRowProps) => { + const { username, phone } = contactHandles(contact); + const usernameText = username ? `@${username}` : null; + const phoneText = phone ? `+${phone.replace(/^\+/, '')}` : null; + const linked = Boolean(contact.dm_room_mxid); + const avatarSrc = useMxcAvatar(api, contact.avatar_url); + return ( +
+ +
+
+ + {contact.name || usernameText || phoneText || contact.id} + +
+ {usernameText || phoneText ? ( + // Separate spans (not a ' · '-joined string) so narrow viewports + // wrap the phone onto its own line instead of ellipsizing both. +
+ {usernameText ? {usernameText} : null} + {phoneText ? {phoneText} : null} +
+ ) : null} +
+ +
+ ); +}; + +const contactSearchHaystack = (contact: Contact): string => { + const { username, phone } = contactHandles(contact); + return [contact.name ?? '', username ?? '', phone ?? ''].join(' ').toLowerCase(); +}; + +const sortContacts = (contacts: Contact[]): Contact[] => + [...contacts].sort((a, b) => + (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' }) + ); + +export const Contacts = ({ + client, + api, + t, + selfId, + onSelfAvatar, + reloadToken, + onLoadingChange, + onOpenRoom, + onError, +}: ContactsProps) => { + const [list, setList] = useState({ kind: 'loading' }); + const [query, setQuery] = useState(''); + const [probe, setProbe] = useState(null); + // One in-flight row action at a time; the value is the contact id (or the + // probe identifier) whose button is busy. + const [busyId, setBusyId] = useState(null); + const aliveRef = useRef(true); + + useEffect( + () => () => { + aliveRef.current = false; + }, + [] + ); + + // Ref-shims so the parent's per-render callback identities don't churn + // `load` (which would re-fetch the contact list on every parent render). + const onSelfAvatarRef = useRef(onSelfAvatar); + onSelfAvatarRef.current = onSelfAvatar; + const onLoadingChangeRef = useRef(onLoadingChange); + onLoadingChangeRef.current = onLoadingChange; + + const load = useCallback(() => { + setList({ kind: 'loading' }); + onLoadingChangeRef.current?.(true); + client + .listContacts() + .then((contacts) => { + onLoadingChangeRef.current?.(false); + if (!aliveRef.current) return; + // Your own address-book entry is hidden from the rows below, but its + // avatar is the best own-avatar source — hand it up before filtering. + const selfEntry = selfId ? contacts.find((c) => c.id === selfId) : undefined; + if (selfEntry?.avatar_url) onSelfAvatarRef.current?.(selfEntry.avatar_url); + setList({ kind: 'ready', contacts: sortContacts(contacts) }); + }) + .catch((err) => { + onLoadingChangeRef.current?.(false); + if (!aliveRef.current) return; + setList({ kind: 'error', message: describeApiError(err, t) }); + }); + }, [client, t, selfId]); + + useEffect(() => { + load(); + // reloadToken is the parent header's refresh button — same load, new tick. + }, [load, reloadToken]); + + // Telegram's address book includes your own entry — hide it; «начать чат + // с собой» reads as a glitch here (Saved Messages is not this surface). + const visibleContacts = useMemo(() => { + if (list.kind !== 'ready') return []; + return selfId ? list.contacts.filter((c) => c.id !== selfId) : list.contacts; + }, [list, selfId]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return visibleContacts; + // `@nick` / `+7 999…` queries should also match the local list. + const bare = needle.replace(/^@/, '').replace(/[\s\-()]/g, ''); + return visibleContacts.filter((c) => { + const haystack = contactSearchHaystack(c); + return haystack.includes(needle) || (bare.length > 0 && haystack.includes(bare)); + }); + }, [visibleContacts, query]); + + const probeCandidate = useMemo(() => detectIdentifier(query), [query]); + + // When the typed identifier exactly matches someone already in the visible + // list, the list row IS the answer — offering a parallel «проверить в + // Telegram» path would just duplicate the same person with two buttons. + // Self is intentionally NOT considered a match here, so probing your own + // number still reaches the «это вы» reply instead of dead-ending. + const probeMatchesLocal = useMemo(() => { + if (!probeCandidate) return false; + if (probeCandidate.kind === 'phone') { + const digits = probeCandidate.value.replace(/\D/g, ''); + return visibleContacts.some( + (c) => (contactHandles(c).phone ?? '').replace(/\D/g, '') === digits + ); + } + const uname = probeCandidate.value.toLowerCase(); + return visibleContacts.some((c) => (contactHandles(c).username ?? '').toLowerCase() === uname); + }, [probeCandidate, visibleContacts]); + + // The probe row hides once its result is stale (query changed). + const activeProbe = + probe && probeCandidate && probe.identifier.value === probeCandidate.value ? probe : null; + + const runProbe = useCallback(() => { + if (!probeCandidate) return; + setProbe({ status: 'checking', identifier: probeCandidate }); + client + .resolveIdentifier(probeCandidate.value) + .then((contact) => { + if (!aliveRef.current) return; + setProbe( + contact + ? { status: 'found', identifier: probeCandidate, contact } + : { status: 'not-found', identifier: probeCandidate } + ); + }) + .catch((err) => { + if (!aliveRef.current) return; + setProbe(null); + onError(describeApiError(err, t)); + }); + }, [client, probeCandidate, onError, t]); + + // After we hand a room to the host, the widget normally unmounts when the + // host navigates (≤15 s: BotWidgetMount waits for the fresh portal to + // sync+join first). If navigation never happens — host-side parse failure, + // room never syncing — re-enable the button instead of spinning forever. + const busyResetTimer = useRef(null); + useEffect( + () => () => { + if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current); + }, + [] + ); + const handOffToHost = useCallback( + (roomId: string) => { + onOpenRoom(roomId); + if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current); + busyResetTimer.current = window.setTimeout(() => { + if (aliveRef.current) setBusyId(null); + }, 20_000); + }, + [onOpenRoom] + ); + + const startChat = useCallback( + (contact: Contact, busyKey: string) => { + if (busyId !== null) return; + setBusyId(busyKey); + if (contact.dm_room_mxid) { + // Portal already exists — pure navigation, the host takes it from + // here (the widget unmounts on route change). + handOffToHost(contact.dm_room_mxid); + return; + } + client + .createDm(contact.id) + .then((resolved) => { + if (!aliveRef.current) return; + if (resolved.dm_room_mxid) { + handOffToHost(resolved.dm_room_mxid); + } else { + setBusyId(null); + onError(t('error.generic', { reason: 'no room id' })); + } + }) + .catch((err) => { + if (!aliveRef.current) return; + setBusyId(null); + onError(describeApiError(err, t)); + }); + }, + [busyId, client, handOffToHost, onError, t] + ); + + const renderRow = (contact: Contact, busyKey: string) => ( + startChat(contact, busyKey)} + /> + ); + + const renderProbeArea = () => { + if (!probeCandidate || probeMatchesLocal) return null; + if (activeProbe?.status === 'found') { + // Resolving your own number/username is technically valid (Telegram's + // «Saved Messages»), but reads as a glitch in a contact picker — + // surface it as «это вы» instead of an actionable row. + if (selfId && activeProbe.contact.id === selfId) { + return ( +
+
{t('contacts.probe-self')}
+
+ ); + } + return ( +
+
{t('contacts.probe-found')}
+ {renderRow(activeProbe.contact, `probe:${activeProbe.contact.id}`)} +
+ ); + } + if (activeProbe?.status === 'not-found') { + return ( +
+
+ {t('contacts.probe-not-found', { handle: activeProbe.identifier.display })} +
+
+ ); + } + const checking = activeProbe?.status === 'checking'; + return ( + + ); + }; + + return ( +
+
{ + e.preventDefault(); + // Enter in the search field fires the probe when the input looks + // like an identifier — the keyboard path to «написать по номеру». + if (probeCandidate && !probeMatchesLocal && activeProbe?.status !== 'checking') { + runProbe(); + } + }} + > + + setQuery((e.currentTarget as HTMLInputElement).value)} + /> +
+ + {!query && list.kind === 'ready' ?
{t('contacts.hint')}
: null} + + {renderProbeArea()} + + {list.kind === 'loading' ? ( +
+ + {t('contacts.loading')} +
+ ) : null} + + {list.kind === 'error' ? ( +
+ {list.message || t('contacts.error')} + +
+ ) : null} + + {list.kind === 'ready' ? ( + <> + {filtered.length > 0 ? ( +
{filtered.map((c) => renderRow(c, c.id))}
+ ) : ( +
+ {query.trim() ? t('contacts.empty-filtered') : t('contacts.empty')} +
+ )} + + ) : null} +
+ ); +}; diff --git a/apps/widget-telegram/src/errors.ts b/apps/widget-telegram/src/errors.ts new file mode 100644 index 00000000..fd6c6cac --- /dev/null +++ b/apps/widget-telegram/src/errors.ts @@ -0,0 +1,47 @@ +// Map transport/bridge errors to localized human copy. The bridge surfaces +// Telegram-side failures as mautrix RespError JSON ({errcode, error}) — the +// interesting Telegram error names (FLOOD_WAIT, PHONE_NUMBER_INVALID, …) +// arrive embedded in the message text, so substring matching is the +// authoritative option short of forking the bridge. + +import { ProvisioningError } from './provisioning'; +import type { T } from './i18n'; + +export const describeApiError = (err: unknown, t: T): string => { + if (err instanceof ProvisioningError) { + const text = `${err.errcode ?? ''} ${err.message}`; + if (text.includes('FLOOD_WAIT') || text.includes('FLOOD_PREMIUM_WAIT')) { + return t('error.flood'); + } + if (text.includes('PHONE_NUMBER_INVALID') || text.includes('PHONE_NUMBER_UNOCCUPIED')) { + return t('error.phone-invalid'); + } + if (text.includes('PHONE_NUMBER_BANNED')) return t('error.phone-banned'); + if (err.errcode === 'FI.MAU.BRIDGE.LOGIN_TIMED_OUT') return t('error.login-timeout'); + if (err.errcode === 'FI.MAU.BRIDGE.LOGIN_CANCELLED') return t('error.login-restart'); + if (err.errcode === 'FI.MAU.BRIDGE.TOO_MANY_LOGINS') return t('error.too-many-logins'); + // Login process evaporated server-side (bridge deletes it on errors and + // after its 30-minute deadline) — the only recovery is starting over. + if (err.httpStatus === 404) return t('error.login-restart'); + if ( + err.errcode === 'M_MISSING_TOKEN' || + err.errcode === 'M_UNKNOWN_TOKEN' || + err.errcode === 'M_FORBIDDEN' + ) { + return t('error.auth'); + } + return t('error.generic', { reason: err.message }); + } + // Host refused to issue OpenID creds — an operator-side config gap + // (missing `vojo.openid` capability), not a transient failure; say so. + if (err instanceof Error && err.message.includes('blocked by host')) { + return t('error.openid-blocked'); + } + if (err instanceof Error && err.message.includes('OpenID')) return t('error.auth'); + // fetch() network failures surface as TypeError; transport timeouts abort + // with AbortError (DOMException, also instanceof Error with name). + if (err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError')) { + return t('error.network'); + } + return t('error.generic', { reason: err instanceof Error ? err.message : String(err) }); +}; diff --git a/apps/widget-telegram/src/i18n/en.ts b/apps/widget-telegram/src/i18n/en.ts index 1897441d..a2db9db0 100644 --- a/apps/widget-telegram/src/i18n/en.ts +++ b/apps/widget-telegram/src/i18n/en.ts @@ -4,96 +4,139 @@ import type { StringKey } from './ru'; export const EN: Record = { - 'status.unknown': 'Checking status…', - 'status.disconnected': 'Telegram not linked', - 'status.connected': 'Telegram linked', - 'status.connected-as': 'Telegram linked as {handle}', - 'status.logging-out': 'Signing out…', - 'status.qr-verifying': 'Verifying sign-in…', - 'card.login.name': 'Sign in by phone number', - 'card.login.desc': 'Code arrives in Telegram or via SMS', + // --- Status pill --------------------------------------------------------- + 'status.checking': 'Checking status…', + 'status.disconnected': 'Telegram is not linked', + 'status.connected-as': 'Linked as {handle}', + + // --- Action cards ---------------------------------------------------------- + 'card.login.name': 'Sign in with phone', + 'card.login.desc': 'The code arrives in Telegram or via SMS', 'card.login-qr.name': 'Sign in with QR code', 'card.login-qr.desc': 'Scan a QR code from the Telegram app on your phone', - 'card.refresh.aria': 'Refresh status', - 'card.refresh.label': 'Refresh status', 'card.refresh.name': 'Refresh status', 'card.refresh.desc': 'Re-check whether Telegram is linked', 'card.refresh.in-flight': 'Checking…', + 'card.logout.name': 'Sign out of Telegram', + 'card.logout.desc': 'End the session on this account', + 'card.logout.confirm-prompt': 'Sign out for real?', + 'card.logout.confirm-yes': 'Sign out', + 'card.logout.confirm-no': 'Cancel', + + // --- About panel ----------------------------------------------------------- 'card.about.name': 'How the Telegram bot works', - 'card.about.desc': 'Sign-in, safety, and source code', + 'card.about.desc': 'Sign-in, security, and source code', 'about.title': 'About the Telegram bot', 'about.body-1': - 'This bot connects Telegram to Vojo. After sign-in, your private chats and groups from Telegram will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal Telegram messages.', + 'This bot connects Telegram to Vojo. After signing in, your Telegram DMs and groups appear in the Vojo chat list, and replies sent from Vojo are delivered to your contacts as regular Telegram messages.', 'about.body-2': - 'Sign-in uses your phone number and the code from Telegram, just like signing in on a new device. If you have two-step verification enabled, Telegram will also ask for your cloud password.', + 'Signing in takes your phone number and a code from Telegram — the same as signing in on a new device. If you use two-step verification, Telegram will additionally ask for your cloud password.', 'about.body-3': - 'The connection runs through the open-source mautrix-telegram bridge. It creates a Telegram session on the Vojo server and uses it to connect Telegram with your Vojo account: receive messages from Telegram and send your replies back.', - 'about.github-label': 'The bridge source code is public on GitHub:', + 'The connection runs through the open-source mautrix-telegram bridge. It creates a Telegram session on the Vojo server and uses it to link Telegram with your Vojo account: receiving messages from Telegram and sending your replies back.', + 'about.github-label': 'The bridge source code is open on GitHub:', 'about.github-url': 'https://github.com/mautrix/telegram', 'about.body-4': - 'You can revoke access at any time — either with the “Sign out of Telegram” button here, or inside Telegram itself under Settings → Devices.', + 'You can revoke access at any time — with the “Sign out of Telegram” button here, or in Telegram itself under “Settings → Devices”.', 'about.close': 'Close', - 'about.aria-close': 'Close “About this bot”', - 'auth-card.phone.title': 'Phone login', + 'about.aria-close': 'Close “About the bot”', + + // --- Phone form ------------------------------------------------------------ + 'auth-card.phone.title': 'Phone sign-in', 'auth-card.phone.label': 'Phone number', - 'auth-card.phone.placeholder': '+15551234567', - 'auth-card.phone.hint': 'SMS may take up to 30 seconds.', + 'auth-card.phone.placeholder': '+79991234567', + 'auth-card.phone.hint': 'SMS delivery can take up to 30 seconds.', 'auth-card.phone.submit': 'Send code', 'auth-card.phone.cooldown': 'Retry in {seconds}s', - 'auth-card.phone.invalid': "This doesn't look like a complete international phone number.", - 'auth-card.code.title': 'Verification code', - 'auth-card.code.label': 'SMS code', + 'auth-card.phone.invalid': 'The number looks incomplete or mistyped.', + + // --- Code form --------------------------------------------------------- + 'auth-card.code.title': 'Confirmation code', + 'auth-card.code.label': 'Code from Telegram or SMS', 'auth-card.code.placeholder': '123456', 'auth-card.code.submit': 'Confirm', - 'auth-card.code.privacy-hint': - 'The Telegram code is visible in the room history — you can clear it manually.', + 'auth-card.code.countdown': 'The code should arrive within {seconds}s', + 'auth-card.code.countdown-done': 'Nothing yet — press “Cancel” and try again.', + + // --- 2FA password form ------------------------------------------------- 'auth-card.password.title': 'Telegram cloud password', 'auth-card.password.hint': - 'Your account has two-factor authentication enabled. Enter your Telegram cloud password — this is not your Vojo password.', + 'Your account has two-step verification enabled. Enter your Telegram cloud password — it is not your Vojo password.', 'auth-card.password.label': 'Password', 'auth-card.password.submit': 'Confirm', 'auth-card.password.show': 'Show', 'auth-card.password.hide': 'Hide', + + // --- Shared form chrome ------------------------------------------------ 'auth-card.cancel': 'Cancel', - 'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.', - 'auth-card.code.countdown': 'Code arriving in {seconds}s', - 'auth-card.code.countdown-done': 'No code yet — tap Cancel and try again.', - 'auth-card.qr.title': 'QR code sign-in', + 'auth-card.waiting-hint': 'The bridge is still thinking… replies can take up to 30 seconds.', + + // --- QR panel ------------------------------------------------------------ + 'auth-card.qr.title': 'QR-code sign-in', 'auth-card.qr.hint': 'Open Telegram on your phone and scan this QR code.', - 'auth-card.qr.preparing': 'Preparing QR code…', - 'auth-card.qr.aria': 'QR code for Telegram 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 Settings → Devices in the Telegram app.', - 'auth-card.qr.step-2': 'Tap “Link Device” and scan this QR code.', - 'auth-card.qr.step-3': - 'If two-step verification is on, enter your cloud password on the next step.', - 'auth-error.invalid-code': 'Code is invalid. Please try again.', - 'auth-error.wrong-password': 'Password is incorrect. Please try again.', - 'auth-error.invalid-value': 'Value not accepted: {reason}', - 'auth-error.submit-failed': 'Telegram refused the input: {reason}', - 'auth-error.login-in-progress': - 'The bot already has another login flow open. Click Cancel and retry.', - 'auth-error.max-logins': 'Login limit reached ({limit}). Log 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.start-failed': 'Failed to start login: {reason}', - 'auth-error.prepare-failed': 'Failed to prepare login: {reason}', - 'card.logout.name': 'Sign out of Telegram', - '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.', + 'auth-card.qr.preparing': 'Preparing the QR code…', + 'auth-card.qr.aria': 'QR code for signing in to Telegram. Scan it with your phone.', + 'auth-card.qr.countdown': '{minutes}:{seconds} left to scan', + 'auth-card.qr.expired': 'The sign-in window expired. Press “Cancel” and try again.', + 'auth-card.qr.step-1': 'Open “Settings → Devices” in Telegram.', + 'auth-card.qr.step-2': 'Tap “Link Desktop Device” and scan this QR code.', + 'auth-card.qr.step-3': 'If you use a cloud password, you will enter it in the next step.', + + // --- Inline form errors -------------------------------------------------- + 'error.code-incorrect': 'Incorrect code. Try again.', + 'error.password-incorrect': 'Incorrect password. Try again.', + + // --- Global errors / notices --------------------------------------------- + 'error.network': 'No connection to the server. Check your internet and try again.', + 'error.auth': + 'Could not verify your account with the bridge. Reload the page; if that does not help, try again later.', + 'error.openid-blocked': + 'The host did not grant the widget sign-in permission — the bot is missing the vojo.openid capability in config.json.', + 'error.flood': 'Telegram asks to wait: too many attempts. Try again later.', + 'error.phone-invalid': 'Telegram rejected this number. Check it and try again.', + 'error.phone-banned': 'This number is banned on Telegram.', + 'error.login-timeout': 'The sign-in window expired. Start over.', + 'error.login-restart': 'The sign-in session was lost. Start over.', + 'error.too-many-logins': 'Login limit reached. Sign out of the current account first.', + 'error.generic': 'Something went wrong: {reason}', + 'notice.login-success': 'Telegram linked! Your chats will appear in the list within a minute.', + 'notice.logged-out': 'Telegram session ended.', + + // --- Contacts ------------------------------------------------------------ + 'card.contacts.name': 'Contacts', + 'card.contacts.desc': 'Telegram address book: search by name, @username or phone', + 'contacts.back': 'Back', + 'contacts.search-placeholder': 'Name, @username or +phone…', + 'contacts.hint': + 'These are your Telegram address-book contacts. Pick who to start a chat with in Vojo — the rest stay right here.', + 'contacts.loading': 'Loading contacts…', + 'contacts.error': 'Could not load contacts.', + 'contacts.retry': 'Retry', + 'contacts.empty': 'Your Telegram address book is empty.', + 'contacts.empty-filtered': 'Nobody matches that name.', + 'contacts.start-chat': 'Start chat', + 'contacts.open-chat': 'Open chat', + 'contacts.creating': 'Creating the chat…', + 'contacts.opening': 'Opening…', + 'contacts.probe-check': 'Check {handle} on Telegram', + 'contacts.probe-checking': 'Checking {handle}…', + 'contacts.probe-not-found': '{handle} was not found on Telegram.', + 'contacts.probe-found': 'Found them! You can start a chat.', + 'contacts.probe-self': 'That is your own account.', + 'contacts.refresh': 'Refresh list', + + // --- Account tab ----------------------------------------------------------- + 'account.state-bad': + 'The bridge reports a connection problem: {reason}. Try signing out and linking Telegram again.', + + // --- Boot / config --------------------------------------------------------- + 'boot.connecting': 'Connecting to the bridge…', + 'config.missing.title': 'Server-side setup required', + 'config.missing.body': + 'The widget talks to the bridge API, but its address is missing from the configuration (experience.provisioningUrl in config.json) or the vojo.openid capability was not granted.', + 'error.retry': 'Retry', + + // --- Bootstrap failure ------------------------------------------------- '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}.', + 'bootstrap.missing-params': 'Required URL params are missing: {names}.', + 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at the {route} route.', }; diff --git a/apps/widget-telegram/src/i18n/ru.ts b/apps/widget-telegram/src/i18n/ru.ts index 714692a2..8263d3bd 100644 --- a/apps/widget-telegram/src/i18n/ru.ts +++ b/apps/widget-telegram/src/i18n/ru.ts @@ -4,51 +4,28 @@ // 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 (avatar/name/handle/description) — -// that block lives in the host's BotShellHero. Status is surfaced inline -// inside the relevant section, with active labels («Войдите в Telegram» -// instead of passive «Не подключён»). Mid-flow states (awaiting_*) don't -// have status labels because the open form is itself the indicator. export const RU = { - // --- Inline section status --------------------------------------------- - // Status pill mirrors the connected pill («Telegram привязан»). Earlier - // copy used «Войдите в Telegram», which read as a duplicate of the login - // card sitting directly below — the pill should describe state, the - // card should carry the action. - 'status.unknown': 'Проверка статуса…', + // --- Status pill --------------------------------------------------------- + 'status.checking': 'Проверка статуса…', 'status.disconnected': 'Telegram не привязан', - 'status.connected': 'Telegram привязан', - 'status.connected-as': 'Telegram привязан как {handle}', - 'status.logging-out': 'Завершение сеанса…', - // QR-вход: после успешного скана мост стирает QR и переходит к 2FA или - // подтверждению логина. Это короткий промежуточный pill между скан-моментом - // и реальным результатом — обычно секунды. - 'status.qr-verifying': 'Проверяем вход…', - // --- Section headers --------------------------------------------------- - // Human-readable name; bridgev2's `!tg login` is sent under the hood, but - // surfacing «/login» on the button makes the UI read like a CLI. + 'status.connected-as': 'Привязан как {handle}', + + // --- Action cards ---------------------------------------------------------- 'card.login.name': 'Войти по номеру', - // Card desc is descriptive (noun-style), not a third call-to-action — the - // section status carries state, the card carries action + how-to. The - // mention of «приложение или SMS» reflects Telegram's actual delivery: - // for users already logged in on another device the OTP arrives as a - // Telegram-app push first, only falling back to SMS if no other session. 'card.login.desc': 'Код придёт в Telegram или по SMS', 'card.login-qr.name': 'Войти по QR-коду', 'card.login-qr.desc': 'Отсканировать QR из приложения Telegram на телефоне', - 'card.refresh.aria': 'Обновить статус', - 'card.refresh.label': 'Обновить статус', - // Refresh-as-card variant for the disconnected state where it sits in - // the same `command-grid` as login. Same vocabulary as login card. 'card.refresh.name': 'Обновить статус', 'card.refresh.desc': 'Перепроверить, привязан ли Telegram', - // Shown in the desc slot while a refresh request is in flight (button - // also goes :disabled + spinning icon). Without this the click has no - // visible acknowledgement until the bot replies. 'card.refresh.in-flight': 'Проверяю…', - // --- About panel ------------------------------------------------------- + 'card.logout.name': 'Выйти из Telegram', + 'card.logout.desc': 'Завершить сеанс на этом аккаунте', + 'card.logout.confirm-prompt': 'Точно выйти?', + 'card.logout.confirm-yes': 'Выйти', + 'card.logout.confirm-no': 'Отмена', + + // --- About panel ----------------------------------------------------------- 'card.about.name': 'Как работает Telegram-бот', 'card.about.desc': 'Вход, безопасность и исходный код', 'about.title': 'О боте Telegram', @@ -64,7 +41,8 @@ export const RU = { 'Отозвать доступ можно в любой момент — кнопкой «Выйти из Telegram» здесь, либо в самом Telegram через «Настройки → Устройства».', 'about.close': 'Закрыть', 'about.aria-close': 'Закрыть «О боте»', - // --- Phone form -------------------------------------------------------- + + // --- Phone form ------------------------------------------------------------ 'auth-card.phone.title': 'Вход по номеру', 'auth-card.phone.label': 'Номер телефона', 'auth-card.phone.placeholder': '+79991234567', @@ -72,12 +50,15 @@ export const RU = { 'auth-card.phone.submit': 'Отправить код', 'auth-card.phone.cooldown': 'Повтор через {seconds} сек', 'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.', + // --- Code form --------------------------------------------------------- 'auth-card.code.title': 'Код подтверждения', - 'auth-card.code.label': 'Код из SMS', + 'auth-card.code.label': 'Код из Telegram или SMS', 'auth-card.code.placeholder': '123456', 'auth-card.code.submit': 'Подтвердить', - 'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.', + 'auth-card.code.countdown': 'Код придёт через {seconds} сек', + 'auth-card.code.countdown-done': 'Не пришло — нажмите «Отмена» и попробуйте снова.', + // --- 2FA password form ------------------------------------------------- 'auth-card.password.title': 'Облачный пароль Telegram', 'auth-card.password.hint': @@ -86,66 +67,76 @@ export const RU = { 'auth-card.password.submit': 'Подтвердить', 'auth-card.password.show': 'Показать', 'auth-card.password.hide': 'Скрыть', + // --- Shared form chrome ------------------------------------------------ 'auth-card.cancel': 'Отмена', - 'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.', - 'auth-card.code.countdown': 'Код придёт через {seconds} сек', - 'auth-card.code.countdown-done': 'Не пришло — нажмите «Отмена» и попробуйте снова.', - // --- QR form ----------------------------------------------------------- - // Заголовок и подсказка над самим QR. Шаги ниже расписывают, где открыть - // сканер в приложении Telegram — без этого у пользователя без опыта - // обычно теряется минута на поиски пункта меню. + 'auth-card.waiting-hint': 'Мост ещё думает… ответ может идти до 30 секунд.', + + // --- QR panel ------------------------------------------------------------ 'auth-card.qr.title': 'Вход по QR-коду', 'auth-card.qr.hint': 'Откройте Telegram на телефоне и отсканируйте этот QR-код.', 'auth-card.qr.preparing': 'Готовим QR-код…', 'auth-card.qr.aria': 'QR-код для входа в Telegram. Отсканируйте его телефоном.', - // Обратный отсчёт до серверного таймаута моста (10 минут). Сам QR - // ротируется ~раз в 30 секунд (Telegram-серверный пуш через MTProto), - // и тут отображается всегда свежий — отсчёт показывает оставшееся - // окно ВСЕГО ВХОДА, а не валидность конкретного отображаемого QR. - // Формат «MM:SS» нагляднее «через N секунд» при минутном масштабе. 'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}', 'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', - // Шаги для пользователя — соответствуют пути в актуальной версии Telegram - // на момент M13. Если Telegram перенесёт пункт меню, это правится тут - // одной строкой; код кнопок не зависит от текста шагов. 'auth-card.qr.step-1': 'Откройте «Настройки → Устройства» в Telegram.', 'auth-card.qr.step-2': 'Нажмите «Подключить устройство» и отсканируйте этот QR-код.', 'auth-card.qr.step-3': 'Если включён облачный пароль — введите его в следующем шаге.', - // --- Inline errors ----------------------------------------------------- - 'auth-error.invalid-code': 'Код неверный. Попробуйте снова.', - 'auth-error.wrong-password': 'Пароль неверный. Попробуйте снова.', - 'auth-error.invalid-value': 'Значение не принято: {reason}', - 'auth-error.submit-failed': 'Telegram не принял ввод: {reason}', - 'auth-error.login-in-progress': - 'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.', - 'auth-error.max-logins': - 'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.', - 'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.', - 'auth-error.start-failed': 'Не удалось начать вход: {reason}', - 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', - // --- Logout ------------------------------------------------------------ - // Same readability rationale as `card.login.name` — the bridgev2 command - // name belongs in the wire payload, not on the button. - 'card.logout.name': 'Выйти из Telegram', - '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 содержит - // токен `tg://login?token=…`, который мост стирает после скана; сохранять - // его в DOM-логе виджета означало бы пережить эту защиту. Поэтому в логе - // только нейтральные диагностические строки. - 'diag.qr-issued': 'QR-код обновлён.', - 'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.', + + // --- Inline form errors -------------------------------------------------- + 'error.code-incorrect': 'Код неверный. Попробуйте снова.', + 'error.password-incorrect': 'Пароль неверный. Попробуйте снова.', + + // --- Global errors / notices --------------------------------------------- + 'error.network': 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.', + 'error.auth': + 'Не удалось подтвердить ваш аккаунт у моста. Обновите страницу; если не помогает — попробуйте позже.', + 'error.openid-blocked': + 'Хост не выдал виджету разрешение на вход — в config.json у бота нет capability vojo.openid.', + 'error.flood': 'Telegram просит подождать: слишком много попыток. Попробуйте позже.', + 'error.phone-invalid': 'Telegram не принял этот номер. Проверьте его и попробуйте снова.', + 'error.phone-banned': 'Этот номер заблокирован в Telegram.', + 'error.login-timeout': 'Время входа истекло. Начните вход заново.', + 'error.login-restart': 'Сессия входа потерялась. Начните вход заново.', + 'error.too-many-logins': 'Достигнут лимит привязанных аккаунтов. Сначала выйдите из текущего.', + 'error.generic': 'Что-то пошло не так: {reason}', + 'notice.login-success': 'Telegram привязан! Чаты появятся в списке в течение минуты.', + 'notice.logged-out': 'Сеанс Telegram завершён.', + + // --- Contacts ------------------------------------------------------------ + 'card.contacts.name': 'Контакты', + 'card.contacts.desc': 'Записная книжка Telegram: поиск по имени, нику или номеру', + 'contacts.back': 'Назад', + 'contacts.search-placeholder': 'Имя, @ник или +номер…', + 'contacts.hint': + 'Это контакты вашей записной книжки Telegram. Выберите, с кем начать чат в Vojo, — остальные никуда не денутся.', + 'contacts.loading': 'Загружаем контакты…', + 'contacts.error': 'Не удалось загрузить контакты.', + 'contacts.retry': 'Повторить', + 'contacts.empty': 'В записной книжке Telegram пока пусто.', + 'contacts.empty-filtered': 'Никого не нашли с таким именем.', + 'contacts.start-chat': 'Начать чат', + 'contacts.open-chat': 'Открыть чат', + 'contacts.creating': 'Создаём чат…', + 'contacts.opening': 'Открываем…', + 'contacts.probe-check': 'Проверить {handle} в Telegram', + 'contacts.probe-checking': 'Проверяем {handle}…', + 'contacts.probe-not-found': '{handle} не найден в Telegram.', + 'contacts.probe-found': 'Есть такой! Можно написать.', + 'contacts.probe-self': 'Это ваш собственный аккаунт.', + 'contacts.refresh': 'Обновить список', + + // --- Account tab ----------------------------------------------------------- + 'account.state-bad': + 'Мост сообщает о проблеме с подключением: {reason}. Попробуйте выйти и привязать Telegram заново.', + + // --- Boot / config --------------------------------------------------------- + 'boot.connecting': 'Подключение к мосту…', + 'config.missing.title': 'Нужна настройка на сервере', + 'config.missing.body': + 'Виджет работает через API моста, но его адрес не задан в конфигурации (experience.provisioningUrl в config.json) или не выдано разрешение vojo.openid.', + 'error.retry': 'Повторить', + // --- Bootstrap failure ------------------------------------------------- 'bootstrap.failed': 'Widget не запустился', 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', diff --git a/apps/widget-telegram/src/login.tsx b/apps/widget-telegram/src/login.tsx new file mode 100644 index 00000000..57df3ee4 --- /dev/null +++ b/apps/widget-telegram/src/login.tsx @@ -0,0 +1,748 @@ +// Login flow over the bridgev2 v3 login API. The bridge owns the step +// machine (provisioninglogin.go); we render whatever step it returns: +// +// phone flow: user_input(phone_number) → user_input(2fa_code) +// → [user_input(password)] → complete +// qr flow: display_and_wait(qr) —long-poll→ rotated qr | password | complete +// +// `.incorrect` step_id variants (wrong code / wrong password) keep the login +// process alive — the form stays open with an inline error. Every OTHER step +// error kills the process server-side (doLoginStep deletes it), so the flow +// resets; for the phone form we keep the typed number on screen and +// transparently start a fresh process on resubmit. + +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import qrcodeGenerator from 'qrcode-generator'; +// `/min` metadata (~15 KB gzip) covers all country calling codes + length +// validation. Sufficient for «is this a plausible phone number?» — the +// bridge does the authoritative validation server-side. +import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min'; +import { + ProvisioningClient, + TG_FLOW_PHONE, + TG_FLOW_QR, + isNotFound, + type LoginStep, +} from './provisioning'; +import { describeApiError } from './errors'; +import { EyeBlindIcon, EyeIcon } from './ui'; +import type { T } from './i18n'; + +// --- Flow state ------------------------------------------------------------- + +export type LoginFormKind = 'phone' | 'code' | 'password'; + +export type LoginUi = + | { kind: 'idle' } + | { kind: 'starting'; flow: 'phone' | 'qr' } + | { + kind: 'form'; + form: LoginFormKind; + loginId: string; + stepId: string; + fieldId: string; + busy: boolean; + error?: string; + /** The server-side login process died (its errors are terminal) — the + * next submit transparently starts a fresh process. Phone form only. */ + needsRestart?: boolean; + } + | { kind: 'qr'; loginId: string; stepId: string; url: string }; + +type LoginFlowCallbacks = { + /** A `complete` step landed — refresh whoami and celebrate. */ + onComplete: () => void; + /** Terminal flow error to surface outside the (now closed) form. */ + onError: (message: string) => void; +}; + +export type LoginFlow = { + ui: LoginUi; + start: (flow: 'phone' | 'qr') => void; + submit: (value: string) => void; + cancel: () => void; + /** Phone-submit cooldown deadline (SMS rate-limit guard), null when idle. */ + phoneCooldownEnd: number | null; + /** Last phone number the user typed (display-formatted) — survives + * cancel→reopen so retrying during the SMS cooldown doesn't force + * retyping. */ + lastPhone: string; + rememberPhone: (value: string) => void; +}; + +// Telegram throttles repeat SMS hard. 60 s matches Telegram Desktop's own +// "Resend code" lockout. Armed only when the bridge confirms it dispatched a +// code (the submit resolved into the code step). +const PHONE_COOLDOWN_MS = 60_000; + +export const useLoginFlow = ( + client: ProvisioningClient, + t: T, + callbacks: LoginFlowCallbacks +): LoginFlow => { + const [ui, setUi] = useState({ kind: 'idle' }); + const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null); + + // Latest-wins guards for async work. Bumping the generation invalidates + // every in-flight continuation (QR long-poll loop, submit handlers); + // aborting the controller actually cancels the poll's fetch. + const generation = useRef(0); + const waitAbort = useRef(null); + const lastPhoneRef = useRef(''); + + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + + useEffect( + () => () => { + generation.current += 1; + waitAbort.current?.abort(); + }, + [] + ); + + return useMemo(() => { + const formFromField = (fieldType: string | undefined): LoginFormKind | null => { + if (fieldType === 'phone_number') return 'phone'; + if (fieldType === '2fa_code') return 'code'; + if (fieldType === 'password') return 'password'; + return null; + }; + + const runQrWaitLoop = (loginId: string, firstStepId: string, gen: number): void => { + waitAbort.current?.abort(); + const controller = new AbortController(); + waitAbort.current = controller; + void (async () => { + let stepId = firstStepId; + for (;;) { + let step: LoginStep; + try { + // Blocks server-side until the QR token rotates (~30 s), the + // scan succeeds, or the login dies. + step = await client.loginWait(loginId, stepId, controller.signal); + } catch (err) { + if (gen !== generation.current || controller.signal.aborted) return; + setUi({ kind: 'idle' }); + // A 404 here means the bridge deleted the login process — for a + // QR that's «the sign-in window expired», not a generic failure. + callbacksRef.current.onError( + isNotFound(err) ? t('error.login-timeout') : describeApiError(err, t) + ); + return; + } + if (gen !== generation.current) return; + if (step.type === 'display_and_wait') { + const data = step.display_and_wait?.data; + if (step.display_and_wait?.type === 'qr' && data) { + stepId = step.step_id; + setUi({ kind: 'qr', loginId, stepId: step.step_id, url: data }); + continue; + } + setUi({ kind: 'idle' }); + callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); + return; + } + applyStep(step, gen); + return; + } + })(); + }; + + const applyStep = (step: LoginStep, gen: number): void => { + if (gen !== generation.current) return; + if (step.type === 'complete') { + setUi({ kind: 'idle' }); + callbacksRef.current.onComplete(); + return; + } + if (step.type === 'user_input') { + const field = step.user_input?.fields?.[0]; + const form = formFromField(field?.type); + if (!field || !form) { + setUi({ kind: 'idle' }); + callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); + void client.loginCancel(step.login_id).catch(() => undefined); + return; + } + const error = step.step_id.endsWith('.incorrect') + ? t(form === 'password' ? 'error.password-incorrect' : 'error.code-incorrect') + : undefined; + setUi({ + kind: 'form', + form, + loginId: step.login_id, + stepId: step.step_id, + fieldId: field.id, + busy: false, + error, + }); + return; + } + if (step.type === 'display_and_wait') { + const data = step.display_and_wait?.data; + if (step.display_and_wait?.type === 'qr' && data) { + setUi({ kind: 'qr', loginId: step.login_id, stepId: step.step_id, url: data }); + runQrWaitLoop(step.login_id, step.step_id, gen); + return; + } + } + // cookies / unknown display types — nothing the Telegram connector + // ships today. Bail out coherently instead of rendering nothing. + setUi({ kind: 'idle' }); + callbacksRef.current.onError(t('error.generic', { reason: step.step_id })); + void client.loginCancel(step.login_id).catch(() => undefined); + }; + + const start = (flow: 'phone' | 'qr'): void => { + generation.current += 1; + const gen = generation.current; + setUi({ kind: 'starting', flow }); + void client + .loginStart(flow === 'phone' ? TG_FLOW_PHONE : TG_FLOW_QR) + .then((step) => { + if (gen !== generation.current) { + // User cancelled while the start was in flight — don't leave an + // orphaned login process on the bridge for its 30-minute TTL. + void client.loginCancel(step.login_id).catch(() => undefined); + return; + } + applyStep(step, gen); + }) + .catch((err) => { + if (gen !== generation.current) return; + setUi({ kind: 'idle' }); + callbacksRef.current.onError(describeApiError(err, t)); + }); + }; + + const submit = (value: string): void => { + if (ui.kind !== 'form' || ui.busy) return; + const snapshot = ui; + const gen = generation.current; + setUi({ ...snapshot, busy: true, error: undefined }); + void (async () => { + try { + let step: LoginStep; + if (snapshot.needsRestart && snapshot.form === 'phone') { + // Previous process died on a terminal error — restart + // transparently so «fix the typo and resubmit» just works. + const fresh = await client.loginStart(TG_FLOW_PHONE); + const freshField = fresh.user_input?.fields?.[0]; + if (fresh.type !== 'user_input' || !freshField) { + applyStep(fresh, gen); + return; + } + step = await client.loginSubmitInput(fresh.login_id, fresh.step_id, { + [freshField.id]: value, + }); + } else { + step = await client.loginSubmitInput(snapshot.loginId, snapshot.stepId, { + [snapshot.fieldId]: value, + }); + } + if (gen !== generation.current) return; + // The phone-number submit resolving into a user_input step means + // Telegram dispatched a code — arm the SMS-resend cooldown. + if (snapshot.form === 'phone' && step.type === 'user_input') { + setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS); + } + applyStep(step, gen); + } catch (err) { + if (gen !== generation.current) return; + const message = describeApiError(err, t); + if (snapshot.form === 'phone') { + setUi({ ...snapshot, busy: false, error: message, needsRestart: true }); + } else { + // Code/password context is gone server-side; reopening the + // form would submit into a deleted process. + setUi({ kind: 'idle' }); + callbacksRef.current.onError(message); + } + } + })(); + }; + + const cancel = (): void => { + generation.current += 1; + waitAbort.current?.abort(); + // 'starting' has no loginId yet — the stale-generation check in + // start().then() cancels the just-created process when it lands. + // phoneCooldownEnd deliberately SURVIVES cancel: it guards Telegram's + // SMS rate limit, and a cancel→restart loop must not reset it (the + // submit button labels the remaining wait, so this reads as intended). + const loginId = ui.kind === 'form' || ui.kind === 'qr' ? ui.loginId : undefined; + if (loginId) void client.loginCancel(loginId).catch(() => undefined); + setUi({ kind: 'idle' }); + }; + + return { + ui, + start, + submit, + cancel, + phoneCooldownEnd, + lastPhone: lastPhoneRef.current, + rememberPhone: (value: string) => { + lastPhoneRef.current = value; + }, + }; + // `ui` MUST stay in the deps: submit/cancel read it via closure capture + // (not refs), so dropping it would freeze them on a stale snapshot. + // Regenerating the flow object per ui change is cheap — nothing + // downstream memoizes on its identity. + }, [client, t, ui, phoneCooldownEnd]); +}; + +// --- Shared form helpers ------------------------------------------------------ + +// Hint shown when a submit round-trip is slow (Telegram-side latency). +const STILL_WAITING_DELAY_MS = 8_000; + +const useStillWaiting = (active: boolean): boolean => { + const [show, setShow] = useState(false); + useEffect(() => { + setShow(false); + if (!active) return undefined; + const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS); + return () => window.clearTimeout(timer); + }, [active]); + return show; +}; + +// Tick once per second while a future timestamp is still in the future. +export const useCooldownSeconds = (until: number | null): number => { + const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0); + const [seconds, setSeconds] = useState(compute); + useEffect(() => { + if (!until) { + setSeconds(0); + return undefined; + } + setSeconds(compute()); + const timer = window.setInterval(() => { + const next = Math.max(0, Math.ceil((until - Date.now()) / 1000)); + setSeconds(next); + if (next <= 0) window.clearInterval(timer); + }, 1000); + return () => window.clearInterval(timer); + // `compute` is referentially fresh each render but captures `until`; + // the effect only needs to re-run when `until` itself changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [until]); + return seconds; +}; + +const useCountdown = (initialSeconds: number): number => { + const [remaining, setRemaining] = useState(initialSeconds); + useEffect(() => { + if (remaining <= 0) return undefined; + const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000); + return () => window.clearTimeout(timer); + }, [remaining]); + return remaining; +}; + +// --- Phone form --------------------------------------------------------------- + +// Minimum digit count before we'd dare call a number «invalid» — below this +// the user is still typing the country prefix. +const PHONE_MIN_DIGITS_FOR_VALIDATION = 7; + +const phoneToE164 = (raw: string): string => { + const cleaned = raw.replace(/[^\d+]/g, ''); + if (cleaned.length === 0) return ''; + return cleaned.startsWith('+') ? cleaned : `+${cleaned}`; +}; + +// AsYouType is stateful — use a fresh instance per call so mid-string edits +// (paste, backspace) can't desync the formatter from the input value. +type PhoneFormat = { formatted: string; country: string | undefined }; +const formatPhoneInput = (raw: string): PhoneFormat => { + const e164 = phoneToE164(raw); + if (!e164) return { formatted: '', country: undefined }; + const formatter = new AsYouType(); + const formatted = formatter.input(e164); + return { formatted, country: formatter.getCountry() }; +}; + +// ISO 3166-1 alpha-2 → regional-indicator emoji. 'RU' → 🇷🇺. +const countryToFlagEmoji = (cc: string | undefined): string => { + if (!cc || cc.length !== 2) return ''; + const codePoints = cc + .toUpperCase() + .split('') + .map((c) => 127397 + c.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + +type FormProps = { + flow: LoginFlow; + t: T; +}; + +export const PhoneForm = ({ flow, t }: FormProps) => { + const ui = flow.ui.kind === 'form' ? flow.ui : null; + // Pre-fill the number the user typed in a previous attempt — a cancel + // during the SMS cooldown shouldn't cost them the input. + const [value, setValue] = useState(() => flow.lastPhone); + const [country, setCountry] = useState(() => + flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined + ); + const inputRef = useRef(null); + const busy = ui?.busy ?? false; + const stillWaiting = useStillWaiting(busy); + const cooldownSeconds = useCooldownSeconds(flow.phoneCooldownEnd); + const inCooldown = cooldownSeconds > 0; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const e164 = phoneToE164(value); + const digitsCount = e164.replace('+', '').length; + const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION; + // Soft hint, not a hard gate — libphonenumber metadata lags newly + // allocated pools and the bridge has the authoritative word. + const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164); + + const onSubmit = (event: Event) => { + event.preventDefault(); + if (!e164 || busy || inCooldown || !hasEnoughDigits) return; + flow.rememberPhone(value); + flow.submit(e164); + }; + + const submitLabel = inCooldown + ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) }) + : t('auth-card.phone.submit'); + const flagEmoji = countryToFlagEmoji(country); + const error = ui?.error; + + return ( +
+
{t('auth-card.phone.title')}
+ +
+
+ {flagEmoji ? ( + + ) : null} + { + const raw = (e.currentTarget as HTMLInputElement).value; + const next = formatPhoneInput(raw); + setValue(next.formatted); + setCountry(next.country); + }} + disabled={busy} + /> +
+ + +
+
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} + {error ?
{error}
: null} + {busy && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// --- Code form ---------------------------------------------------------------- + +const CODE_COUNTDOWN_SECONDS = 30; + +export const CodeForm = ({ flow, t }: FormProps) => { + const ui = flow.ui.kind === 'form' ? flow.ui : null; + const [value, setValue] = useState(''); + const inputRef = useRef(null); + const busy = ui?.busy ?? false; + const stillWaiting = useStillWaiting(busy); + const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS); + const error = ui?.error; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const onSubmit = (event: Event) => { + event.preventDefault(); + const trimmed = value.trim(); + if (!trimmed || busy) return; + setValue(''); + flow.submit(trimmed); + }; + + return ( +
+
{t('auth-card.code.title')}
+ +
+ setValue((e.currentTarget as HTMLInputElement).value)} + disabled={busy} + /> + + +
+ {error ?
{error}
: null} + {/* SMS countdown is suppressed while submitting (the bridge-latency + * hint takes over) AND after a wrong-code error — by then the code + * already arrived, so «код придёт через N сек» / «не пришло» copy + * would contradict the error line right above it. */} + {!busy && + !error && + (countdownSeconds > 0 ? ( +
+ {t('auth-card.code.countdown', { seconds: String(countdownSeconds) })} +
+ ) : ( +
{t('auth-card.code.countdown-done')}
+ ))} + {busy && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// --- Password form -------------------------------------------------------------- + +export const PasswordForm = ({ flow, t }: FormProps) => { + const ui = flow.ui.kind === 'form' ? flow.ui : null; + const [value, setValue] = useState(''); + const [reveal, setReveal] = useState(false); + const inputRef = useRef(null); + const busy = ui?.busy ?? false; + const stillWaiting = useStillWaiting(busy); + const error = ui?.error; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const onSubmit = (event: Event) => { + event.preventDefault(); + if (!value || busy) return; + // Drop the plaintext from component state BEFORE the async submit so it + // doesn't linger in the DOM input while the request is in flight. + const password = value; + setValue(''); + flow.submit(password); + }; + + return ( +
+
{t('auth-card.password.title')}
+
{t('auth-card.password.hint')}
+ +
+
+ setValue((e.currentTarget as HTMLInputElement).value)} + disabled={busy} + /> + +
+ + +
+ {error ?
{error}
: null} + {busy && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// --- QR panel --------------------------------------------------------------- + +// bridgev2's server-side login deadline for the telegram connector is 10 +// minutes (LoginTimeout, loginqr.go). Soft countdown — at zero we surface a +// retry hint; the server kills the process on its own. +const QR_TIMEOUT_MS = 10 * 60 * 1000; + +// Error-correction level M: more glare-resilient than L, smaller modules +// than Q — matches Telegram Desktop's own QR-login screen. +const buildQrModules = (data: string): boolean[][] | null => { + if (!data) return null; + try { + const qr = qrcodeGenerator(0, 'M'); + qr.addData(data); + qr.make(); + const count = qr.getModuleCount(); + const matrix: boolean[][] = []; + for (let r = 0; r < count; r += 1) { + const row: boolean[] = []; + for (let c = 0; c < count; c += 1) { + row.push(qr.isDark(r, c)); + } + matrix.push(row); + } + return matrix; + } catch { + return null; + } +}; + +// Render the QR matrix as s inside an SVG. No dangerouslySetInnerHTML, +// no external rendering service — the `tg://login?token=...` URL IS the login +// secret and must never leave the iframe. +type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string }; +const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => { + const count = matrix.length; + const margin = 4; + const totalUnits = count + margin * 2; + const cellPx = pixelSize / totalUnits; + const rects: ComponentChildren[] = []; + for (let r = 0; r < count; r += 1) { + for (let c = 0; c < count; c += 1) { + if (!matrix[r][c]) continue; + rects.push( + + ); + } + } + return ( + + {rects} + + ); +}; + +type QrPanelProps = { + url: string; + t: T; + onCancel: () => void; +}; + +export const QrPanel = ({ url, t, onCancel }: QrPanelProps) => { + // First-shown timestamp survives QR rotations — the component stays + // mounted while only `url` changes, and the countdown tracks the WHOLE + // login window, not the validity of one displayed token. + const [firstShownAt] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + const matrix = useMemo(() => buildQrModules(url), [url]); + const elapsed = now - firstShownAt; + const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000)); + const expired = elapsed >= QR_TIMEOUT_MS; + + return ( +
+
{t('auth-card.qr.title')}
+
{t('auth-card.qr.hint')}
+
+ {matrix ? ( + // The aria-label describes the PURPOSE of the QR, not its contents — + // the URL itself is the login secret. + + ) : ( +
+ + {t('auth-card.qr.preparing')} +
+ )} +
+ {!expired ? ( +
+ {t('auth-card.qr.countdown', { + minutes: String(Math.floor(remainingSeconds / 60)), + seconds: String(remainingSeconds % 60).padStart(2, '0'), + })} +
+ ) : ( +
{t('auth-card.qr.expired')}
+ )} +
    +
  1. {t('auth-card.qr.step-1')}
  2. +
  3. {t('auth-card.qr.step-2')}
  4. +
  5. {t('auth-card.qr.step-3')}
  6. +
+
+ +
+
+ ); +}; diff --git a/apps/widget-telegram/src/main.tsx b/apps/widget-telegram/src/main.tsx index 5e208b6a..02e999e5 100644 --- a/apps/widget-telegram/src/main.tsx +++ b/apps/widget-telegram/src/main.tsx @@ -2,7 +2,7 @@ import { render } from 'preact'; import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; -import { WidgetApi, buildCapabilities } from './widget-api'; +import { WidgetApi } from './widget-api'; import './styles.css'; // Input-mode detector for hover styling. CSS gates `:hover` and @@ -18,20 +18,13 @@ import './styles.css'; // tap on Android lands in 'touch' mode in the same render frame as the // synthesised hover would paint. // -// Initial mode is plain 'mouse'. matchMedia-based guessing was tried -// here and dropped — every interaction-media query is mis-reported on at -// least one shipping device: Capacitor Android WebView falsely matches -// `hover: hover` and `any-pointer: fine` on pure-touch phones; -// Samsung / OnePlus / Moto Androids expose a virtual-mouse HID and -// falsely match `pointer: fine`; older Firefox-on-Windows desktops -// reported `pointer: coarse` despite a real mouse. Defaulting to 'mouse' -// is strictly no worse than any of those queries on any device: a -// desktop / hybrid user gets hover affordances from frame zero, and a -// touch user cannot trigger `:hover` before tapping because there is no -// pointer hovering anything — by the time the first tap fires -// `:hover` (synthesised), our listener has already moved the attribute -// to 'touch'. Pen / stylus also lands in 'touch' (pointerType is `pen`, -// matched by the `!== 'mouse'` branch). +// Initial mode is plain 'mouse' — matchMedia-based guessing was tried and +// dropped: every interaction-media query is mis-reported on at least one +// shipping device (see git history for the survey). Defaulting to 'mouse' +// is strictly no worse on any device: a desktop user gets hover from frame +// zero, and a touch user cannot trigger `:hover` before tapping — by the +// time the first tap fires, our listener has already moved the attribute +// to 'touch'. const setInputMode = (mode: 'touch' | 'mouse'): void => { document.documentElement.dataset.input = mode; }; @@ -73,19 +66,12 @@ if (!result.ok) { // through the wrong palette. document.documentElement.dataset.theme = result.bootstrap.theme; - // Instantiate the WidgetApi BEFORE React render. The constructor attaches - // the `window.addEventListener('message', ...)` listener synchronously, - // so by the time the host's ClientWidgetApi fires its capabilities - // request on iframe `load` we're already listening. - // - // The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which - // runs AFTER React's first commit. On a fresh mount the bundle parse + - // initial render took long enough for the host's request to arrive - // after the listener was attached, so it worked by accident. On the - // *second* mount (after «Show chat» → «Show widget») the bundle is - // browser-cached and parses near-instantly; the host's request raced - // ahead of useEffect, the listener missed it, and capability handshake - // hung forever — only the «Соединение с Vojo…» diag line ever showed. - const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId)); + // Instantiate the WidgetApi BEFORE the first render. The constructor + // attaches the `window.addEventListener('message', ...)` listener + // synchronously, so by the time the host's ClientWidgetApi fires its + // capabilities request on iframe `load` we're already listening. On a + // cached-bundle remount the request can race ahead of any useEffect — + // construction at module-load closes that window. + const api = new WidgetApi(result.bootstrap); render(, root); } diff --git a/apps/widget-telegram/src/provisioning.ts b/apps/widget-telegram/src/provisioning.ts new file mode 100644 index 00000000..dc9364cf --- /dev/null +++ b/apps/widget-telegram/src/provisioning.ts @@ -0,0 +1,347 @@ +// Typed client for the mautrix bridgev2 provisioning HTTP API +// (`/_matrix/provision/v3/*`, exposed by Caddy at bootstrap.provisioningUrl). +// +// Wire contract extracted from the bridge sources: +// maunium.net/go/mautrix bridgev2/matrix/provisioning.go (routes + auth) +// bridgev2/matrix/provisioninglogin.go (login step machine) +// bridgev2/provisionutil/{listcontacts,resolveidentifier}.go (response shapes) +// mautrix-telegram pkg/connector/login{,phone,qr}.go (flow/step/field ids) +// +// Auth: every request carries `Authorization: Bearer openid:` — an +// MSC1960 OpenID token requested from the host. The bridge validates it +// against the homeserver's federation API (AuthMiddleware → +// checkFederatedMatrixAuth) and caches the validation for an hour. The +// `user_id` query param tells the middleware whose identity to verify. + +import type { OpenIdCredentials } from './widget-api'; + +// --- Response types -------------------------------------------------------- + +export type BridgeStateInfo = { + state_event?: string; + error?: string; + message?: string; + reason?: string; +}; + +export type WhoamiLogin = { + id: string; + name?: string; + profile?: { + phone?: string; + email?: string; + username?: string; + name?: string; + avatar?: string; + }; + state?: BridgeStateInfo; + space_room?: string; +}; + +export type LoginFlow = { id: string; name?: string; description?: string }; + +export type Whoami = { + network?: { displayname?: string }; + login_flows?: LoginFlow[]; + homeserver?: string; + bridge_bot?: string; + command_prefix?: string; + management_room?: string; + logins?: WhoamiLogin[]; +}; + +export type Contact = { + /** Network user id (numeric Telegram ID as a string). Accepted by + * resolve_identifier / create_dm as-is. */ + id: string; + name?: string; + avatar_url?: string; + /** URI-style identifiers: `telegram:`, `tel:+`. */ + identifiers?: string[]; + /** Ghost MXID (`@telegram_:vojo.chat`). */ + mxid?: string; + /** Existing DM portal room — present ⇒ the chat is already in Vojo. */ + dm_room_mxid?: string; +}; + +export type LoginInputField = { + type: string; // phone_number | 2fa_code | password | ... + id: string; // submit-map key + name?: string; + description?: string; + pattern?: string; +}; + +export type LoginStep = { + /** Present on /login/start and /login/step responses (RespSubmitLogin). */ + login_id: string; + type: 'user_input' | 'display_and_wait' | 'cookies' | 'complete'; + step_id: string; + instructions?: string; + user_input?: { fields: LoginInputField[] }; + display_and_wait?: { + type: 'qr' | 'emoji' | 'code' | 'nothing'; + data?: string; + image_url?: string; + }; + complete?: { user_login_id?: string }; +}; + +// Telegram connector constants (pkg/connector/login*.go). step_ids are +// matched by suffix where the bridge ships `.incorrect` retry variants. +export const TG_FLOW_PHONE = 'phone'; +export const TG_FLOW_QR = 'qr'; + +// --- Errors ---------------------------------------------------------------- + +export class ProvisioningError extends Error { + public readonly errcode?: string; + + public readonly httpStatus: number; + + public constructor(httpStatus: number, errcode: string | undefined, message: string) { + super(message); + this.name = 'ProvisioningError'; + this.httpStatus = httpStatus; + this.errcode = errcode; + } +} + +export const isNotFound = (err: unknown): boolean => + err instanceof ProvisioningError && err.httpStatus === 404; + +// Any 401 means «refresh the OpenID token and retry once» — matching the +// errcodes alone would silently strand flows if the bridge (or a proxy in +// front of it) ever returns a 401 with a different body. +const isAuthError = (err: unknown): boolean => + err instanceof ProvisioningError && + (err.httpStatus === 401 || + err.errcode === 'M_MISSING_TOKEN' || + err.errcode === 'M_UNKNOWN_TOKEN'); + +// --- Client ---------------------------------------------------------------- + +const DEFAULT_TIMEOUT_MS = 20_000; + +// display_and_wait long-polls block server-side until the QR token rotates +// (~30 s) or the login resolves — no client timeout, only caller aborts. +type RequestOptions = { + body?: unknown; + signal?: AbortSignal; + /** null disables the timeout (long-poll). */ + timeoutMs?: number | null; +}; + +export class ProvisioningClient { + private token: string | null = null; + + private tokenExpiresAt = 0; + + private tokenInFlight: Promise | null = null; + + public constructor( + private readonly baseUrl: string, + private readonly userId: string, + private readonly fetchCredentials: () => Promise + ) {} + + // -- auth plumbing -- + + private getToken(force = false): Promise { + if (!force && this.token && Date.now() < this.tokenExpiresAt) { + return Promise.resolve(this.token); + } + // Collapse concurrent refreshes (e.g. contacts + whoami racing on boot) + // into one host round-trip. + if (!this.tokenInFlight) { + this.tokenInFlight = this.fetchCredentials() + .then((creds) => { + this.token = creds.accessToken; + // Refresh a minute early so a token can't expire mid-request. + this.tokenExpiresAt = Date.now() + Math.max(30, creds.expiresIn - 60) * 1000; + return this.token; + }) + .finally(() => { + this.tokenInFlight = null; + }); + } + return this.tokenInFlight; + } + + private async request(method: string, path: string, opts: RequestOptions = {}): Promise { + const attempt = async (forceToken: boolean): Promise => { + const token = await this.getToken(forceToken); + const url = new URL(`${this.baseUrl}${path}`); + url.searchParams.set('user_id', this.userId); + + const controller = new AbortController(); + const onCallerAbort = () => controller.abort(); + opts.signal?.addEventListener('abort', onCallerAbort); + if (opts.signal?.aborted) controller.abort(); + const timeoutMs = opts.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : opts.timeoutMs; + const timer = + timeoutMs === null ? null : window.setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url.toString(), { + method, + signal: controller.signal, + headers: { + Authorization: `Bearer openid:${token}`, + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + if (!res.ok) { + const errBody = (parsed ?? {}) as { errcode?: string; error?: string }; + throw new ProvisioningError( + res.status, + errBody.errcode, + errBody.error ?? `HTTP ${res.status}` + ); + } + return parsed as T; + } finally { + if (timer !== null) window.clearTimeout(timer); + opts.signal?.removeEventListener('abort', onCallerAbort); + } + }; + + try { + return await attempt(false); + } catch (err) { + // Token expired/invalidated between cache and use — refresh once and + // retry. Never retried for long-polls mid-flight aborts (those throw + // AbortError, not ProvisioningError). + if (isAuthError(err)) return attempt(true); + throw err; + } + } + + // -- API surface -- + + public whoami(): Promise { + return this.request('GET', '/v3/whoami'); + } + + public async listContacts(): Promise { + const resp = await this.request<{ contacts?: Contact[] }>('GET', '/v3/contacts'); + return resp.contacts ?? []; + } + + /** Resolve a phone (`+7…`), username (`@nick` / `nick`) or numeric + * Telegram ID. Returns null when the user does not exist on Telegram. */ + public async resolveIdentifier( + identifier: string, + signal?: AbortSignal + ): Promise { + try { + return await this.request( + 'GET', + `/v3/resolve_identifier/${encodeURIComponent(identifier)}`, + { signal } + ); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + } + + /** Resolve + ensure the DM portal room exists. `dm_room_mxid` in the + * response is the room to open. */ + public createDm(identifier: string): Promise { + return this.request('POST', `/v3/create_dm/${encodeURIComponent(identifier)}`, { + // Portal creation = room create + initial sync on the bridge side; + // give it more headroom than a plain GET. + timeoutMs: 45_000, + }); + } + + public loginStart(flowId: string): Promise { + return this.request('POST', `/v3/login/start/${encodeURIComponent(flowId)}`, { + // Phone flow start is instant, QR start waits for Telegram to issue + // the first token — allow for a slow MTProto handshake. + timeoutMs: 45_000, + }); + } + + public loginSubmitInput( + loginId: string, + stepId: string, + fields: Record + ): Promise { + return this.request( + 'POST', + `/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(stepId)}/user_input`, + { body: fields, timeoutMs: 45_000 } + ); + } + + /** Long-poll a display_and_wait step (QR). Resolves with the next step: + * a fresh QR (token rotated), the 2FA password prompt, or complete. */ + public loginWait(loginId: string, stepId: string, signal: AbortSignal): Promise { + return this.request( + 'POST', + `/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent( + stepId + )}/display_and_wait`, + { signal, timeoutMs: null } + ); + } + + public loginCancel(loginId: string): Promise { + return this.request('POST', `/v3/login/cancel/${encodeURIComponent(loginId)}`); + } + + public logout(loginId: string): Promise { + return this.request('POST', `/v3/logout/${encodeURIComponent(loginId)}`); + } +} + +// --- Identifier helpers (shared by contacts search + probe) ----------------- + +/** Mirror of the bridge connector's username regex + * (pkg/connector/startchat.go usernameRe) minus the link prefixes: 5-32 + * chars, starts with a letter, ends with letter/digit, word chars inside. */ +export const USERNAME_RE = /^@?([a-zA-Z]\w{3,30}[a-zA-Z\d])$/; + +/** Loose phone shape — digits with optional separators. Normalised to + * `+` before hitting the API (the bridge resolves phones only with + * the `+` prefix). */ +export const PHONE_RE = /^\+?[\d\s\-()]{7,20}$/; + +export type ProbeIdentifier = { kind: 'phone' | 'username'; value: string; display: string }; + +export const detectIdentifier = (raw: string): ProbeIdentifier | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (PHONE_RE.test(trimmed)) { + const digits = trimmed.replace(/[^\d]/g, ''); + if (digits.length >= 7) { + return { kind: 'phone', value: `+${digits}`, display: `+${digits}` }; + } + } + const username = USERNAME_RE.exec(trimmed); + if (username && !trimmed.includes('__')) { + return { kind: 'username', value: username[1], display: `@${username[1]}` }; + } + return null; +}; + +/** Pull `@username` / `+phone` display strings out of a contact's + * URI-style identifiers. */ +export const contactHandles = (contact: Contact): { username?: string; phone?: string } => { + let username: string | undefined; + let phone: string | undefined; + for (const id of contact.identifiers ?? []) { + if (id.startsWith('telegram:')) username = id.slice('telegram:'.length); + else if (id.startsWith('tel:')) phone = id.slice('tel:'.length); + } + return { username, phone }; +}; diff --git a/apps/widget-telegram/src/state.ts b/apps/widget-telegram/src/state.ts deleted file mode 100644 index d454bc7d..00000000 --- a/apps/widget-telegram/src/state.ts +++ /dev/null @@ -1,1057 +0,0 @@ -// Login state machine — consumes LoginEvent (one per inbound m.notice from -// the bridge bot) and emits a typed UI state. The widget renders forms and -// the status pill from this state, never from raw reply strings. -// -// Multi-reply collapse is implemented here: when the bot emits two notices -// for a single transition (2fa instructions + password re-prompt; invalid -// code + code re-prompt; wrong password + password re-prompt), the second -// notice arrives as `awaiting_password` / `awaiting_code` and the reducer -// recognises it as a no-op against the state already set by the first. -// -// State-gating policy: prompt events (`awaiting_*`) and step-error events -// (`twofa_required`, `invalid_code`, `wrong_password`, `submit_failed`, -// `invalid_value`) are valid only from a *plausible previous state*. -// Without these gates, late prompt-events can resurrect cancelled or -// completed flows — e.g. user submits phone, clicks Cancel, bot's pipeline -// already started a Telegram API call and emits `Please enter your Code…` -// AFTER the cancel reply lands. The reducer here ignores that late prompt -// because we're already `disconnected`. - -import type { LoginEvent, ListedLogin } from './bridge-protocol/types'; - -export type LoginErrorFlag = - | { kind: 'invalid_code' } - | { kind: 'wrong_password' } - | { kind: 'submit_failed'; reason?: string } - | { kind: 'invalid_value'; reason?: string } - | { kind: 'prepare_failed'; reason?: string } - | { kind: 'start_failed'; reason?: string } - | { kind: 'login_in_progress' } - | { kind: 'max_logins'; limit?: number } - | { kind: 'unknown_command' }; - -// A live form is open and waiting for user input. M12.5's hydrate path -// can produce a phone/code/password form OR a QR-scan state — every other -// final state falls through to live `list-logins` reconciliation. -// -// `awaiting_qr_scan` carries: -// tgUrl — `tg://login?token=...` to render as a QR matrix. -// qrEventId — current event id of the QR `m.image`. The bridge -// rotates the token ~every 30 s and edits the original -// event; rotations carry the original id in -// `m.relates_to.event_id` and the state machine matches -// on this field to decide between «same flow, repaint» -// and «something else replaced our QR» (the latter is a -// no-op — we keep the current qrEventId until the bridge -// redacts or sends a new top-level QR). -// firstShownAt — wall-clock ts of the first QR render in this flow. -// Drives the UX countdown to the bridge's 10-min server- -// side LoginTimeout. NOT a hard kill — when the timer -// expires we just show «попробуйте снова». -export type PendingFormState = - | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } - | { kind: 'awaiting_code'; lastError?: LoginErrorFlag } - | { kind: 'awaiting_password'; lastError?: LoginErrorFlag } - | { - kind: 'awaiting_qr_scan'; - tgUrl: string; - qrEventId: string; - firstShownAt: number; - lastError?: LoginErrorFlag; - }; - -export type LoginState = - // Pre-handshake / pre-list-logins. Status pill: --faint. - | { kind: 'unknown' } - // list-logins came back empty, OR logout completed. Status pill: --rose - // (disconnected = needs action). - | { kind: 'disconnected'; lastError?: LoginErrorFlag } - // After "Войти по номеру" — waiting for `Please enter your Phone number`. - // After phone submit — waiting for code prompt OR error reply. - // After code submit (when the bot decided 2fa is needed) — waiting for - // password submission. lastError carries `wrong_password` after a failed - // password retry. Status pill: --amber for all three. - // `awaiting_qr_scan` is the QR-login analog of `awaiting_phone` — the - // bridge has fired its first `m.image` carrying a `tg://login?token=…` - // URL and we're waiting for the user to scan it on their phone. - | PendingFormState - // QR was redacted (i.e. the bridge accepted a scan), but we don't yet - // know whether 2FA is required or login succeeded outright. Held as an - // intermediate spinner until the next bridge signal arrives. Status - // pill: --amber. NOT terminal — `twofa_required` lifts us into - // `awaiting_password`, `login_success` into `connected`. - | { kind: 'qr_verifying' } - // logout in flight — waiting for `Logged out`. Status pill: --amber. - | { kind: 'logging_out'; loginId: string } - // Live session. login carries the parsed handle/numericId from - // `Successfully logged in as ()`, plus the loginId we need - // for `!tg logout `. Status pill: --green. - | { - kind: 'connected'; - handle: string; - numericId?: string; - loginId?: string; - }; - -// States that the hydrate path can restore after a reload. Equals -// PendingFormState (live forms waiting for input) plus `qr_verifying` -// (the brief interstitial after a successful QR scan but before the bot -// emits twofa_required / login_success). Without `qr_verifying` here a -// reload during that ~1 s gap reads the bridge's empty list-logins and -// routes the user to disconnected, losing the scanned QR. -export type HydrateRestoredState = PendingFormState | { kind: 'qr_verifying' }; - -// Outbound user actions the App dispatches. Form-submit actions clear any -// pending lastError; structural transitions (start_login, request_logout, -// cancel_pending) optimistically advance state — the App rolls them back -// on send-failure where the bot would otherwise leave us stuck. -export type LoginAction = - | { kind: 'event'; event: LoginEvent } - | { kind: 'start_login' } // user clicked "Войти по номеру" - | { kind: 'start_qr_login' } // user clicked "Войти по QR-коду" - | { kind: 'submit_phone' } // user clicked submit on phone form - | { kind: 'submit_code' } // user clicked submit on code form - | { kind: 'submit_password' } // user clicked submit on 2fa form - | { kind: 'request_logout'; loginId: string } // user clicked "Выйти" - | { kind: 'cancel_pending' } // user clicked "Отмена" - | { kind: 'hydrate'; state: HydrateRestoredState }; // M12.5 timeline-resume seed - -export const initialLoginState: LoginState = { kind: 'unknown' }; - -const pickConnected = (logins: ListedLogin[]): LoginState => { - if (logins.length === 0) return { kind: 'disconnected' }; - // M12 ships single-account UI (max_logins=1 in the operator's bridge - // config). If a future deployment runs with multiple logins, we still - // surface the first one — multi-account UI is a follow-up phase. The - // loginId here is what the widget will pass to `!tg logout `. - const [first] = logins; - return { - kind: 'connected', - handle: first.name, - loginId: first.id, - }; -}; - -// Whether a `awaiting_code` prompt is plausible from the current state. -// Plausible: just submitted phone (still in awaiting_phone), or the bot -// is re-prompting after invalid_code (we're already in awaiting_code). -const acceptsCodePrompt = (s: LoginState): boolean => - s.kind === 'awaiting_phone' || s.kind === 'awaiting_code'; - -// Whether a `awaiting_password` re-prompt is plausible. The TRANSITION to -// password (from awaiting_code) is driven by `twofa_required`, not by the -// re-prompt itself; the re-prompt only confirms we're still waiting. -const acceptsPasswordReprompt = (s: LoginState): boolean => s.kind === 'awaiting_password'; - -// Whether `twofa_required` is plausible. It can only follow a code submit. -const acceptsTwofa = (s: LoginState): boolean => s.kind === 'awaiting_code'; - -// Whether step-scoped errors (invalid_code, wrong_password, invalid_value, -// submit_failed) should land on a form. Form-scoped errors are dropped -// when no form is open. Shared by the live reducer and the hydrate path — -// the predicate body and the resulting type are identical. -const isFormState = (s: LoginState): s is PendingFormState => - s.kind === 'awaiting_phone' || - s.kind === 'awaiting_code' || - s.kind === 'awaiting_password' || - s.kind === 'awaiting_qr_scan'; - -// Whether `twofa_required` is plausible from the current state. After a code -// submit, after a successful QR scan (which enters qr_verifying), and as a -// late re-entry from awaiting_qr_scan if the bridge skips its redaction -// step (shouldn't happen against bridgev2 v0.2604.0, but the path exists). -const acceptsQrScanTwofa = (s: LoginState): boolean => - s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying'; - -export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { - if (action.kind === 'hydrate') { - // M12.5: hydrate is a one-shot mount-time seed. It races against live - // events that may arrive between `on('ready')` firing and our async - // readTimeline resolving — bridgev2 / cinny's BotWidgetEmbed can push - // a new bot reply via send_event during that window. If a live event - // has already moved us off `unknown`, the live truth wins; the cached - // timeline snapshot is by definition older than what the live event - // just told us. Without this gate, a stale `awaiting_code` from the - // pre-reload session could overwrite a legitimate `connected` that - // arrived during the await. - if (state.kind !== 'unknown') return state; - return action.state; - } - if (action.kind === 'start_login') { - return { kind: 'awaiting_phone' }; - } - if (action.kind === 'start_qr_login') { - // Optimistic transition into a placeholder QR-scan state. The actual QR - // payload arrives as a `qr_displayed` live event and overwrites tgUrl - // / qrEventId / firstShownAt then; until then the panel renders a - // spinner («Готовим QR-код…»). If the `!tg login qr` send fails, the - // App rolls back to `disconnected`. - return { - kind: 'awaiting_qr_scan', - tgUrl: '', - qrEventId: '', - firstShownAt: Date.now(), - }; - } - if (action.kind === 'submit_phone') { - // Stay on the phone form until the bot confirms with `awaiting_code`. - // Optimistic transition to awaiting_code would mis-surface a phone-side - // error (e.g. `submit_failed: PHONE_NUMBER_BANNED`) on the code form. - if (state.kind === 'awaiting_phone') { - return { kind: 'awaiting_phone', lastError: undefined }; - } - return state; - } - if (action.kind === 'submit_code') { - if (state.kind === 'awaiting_code') { - return { kind: 'awaiting_code', lastError: undefined }; - } - return state; - } - if (action.kind === 'submit_password') { - if (state.kind === 'awaiting_password') { - return { kind: 'awaiting_password', lastError: undefined }; - } - return state; - } - if (action.kind === 'request_logout') { - return { kind: 'logging_out', loginId: action.loginId }; - } - if (action.kind === 'cancel_pending') { - // Optimistic: drop straight back to disconnected. The bot's reply will - // be `Login cancelled.` (cancel_ok) or `No ongoing command.` - // (cancel_no_op) — either way the user has signalled they want out. - return { kind: 'disconnected' }; - } - - const event = action.event; - switch (event.kind) { - case 'logins_listed': - // list-logins is the source of truth — accept from any state. - return pickConnected(event.logins); - - case 'not_logged_in': - // Same gating idea as the prompt events: a late-arriving - // `You're not logged in` from a list-logins fired before the user - // started a fresh login flow would otherwise wipe an active form. - // Accept only from states where flipping to disconnected is correct. - // `qr_verifying` is included because the App fires `list-logins` - // as a recovery probe after long QR-verifying stalls — the answer - // there means «scan didn't actually take», back to disconnected. - if ( - state.kind === 'unknown' || - state.kind === 'disconnected' || - state.kind === 'logging_out' || - state.kind === 'qr_verifying' - ) { - return { kind: 'disconnected' }; - } - return state; - - case 'awaiting_phone': - // Bot's "Please enter your Phone number". Only meaningful when we - // initiated phone-login (state already awaiting_phone). From any - // other state — including the late-arriving prompt after a cancel - // — drop it on the floor. - return state.kind === 'awaiting_phone' ? state : state; - - case 'awaiting_code': - // Plausible after submitting phone, or as a re-prompt within the - // code form. Late arrival after cancel/connected/logging_out is - // ignored to avoid resurrecting dead flows. - if (!acceptsCodePrompt(state)) return state; - if (state.kind === 'awaiting_phone') return { kind: 'awaiting_code' }; - return state; - - case 'awaiting_password': - // Pure re-prompt arm. The TRANSITION to awaiting_password is driven - // by `twofa_required` (or `wrong_password`), not by this event. - // Ignored when we're not already on the password form. - if (!acceptsPasswordReprompt(state)) return state; - return state; - - case 'twofa_required': - // First of the two-reply 2fa transition. Valid after a code submit - // (phone-flow path) AND after a successful QR scan (the bridge - // skips straight from QR redaction to «You have two-factor - // authentication enabled.»). Ignored from disconnected/connected - // and from awaiting_phone (where it'd indicate a bridge bug). - if (!acceptsTwofa(state) && !acceptsQrScanTwofa(state)) return state; - return { kind: 'awaiting_password' }; - - case 'invalid_code': - if (state.kind !== 'awaiting_code') return state; - return { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }; - - case 'wrong_password': - if (state.kind !== 'awaiting_password') return state; - return { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }; - - case 'login_success': - // Always honour — even if state somehow drifted, the bridge says we're in. - return { - kind: 'connected', - handle: event.handle, - numericId: event.numericId, - // loginId is unknown until the post-success list-logins fires - // (App.tsx). Until then, logout is gated. - }; - - case 'logout_ok': - // Late `Logged out` from a previous session can arrive while the user - // is mid-new-flow (e.g. they cancelled, started login again, and the - // old logout's reply finally lands). Only honour from logging_out; - // other states keep their flow. - if (state.kind !== 'logging_out') return state; - return { kind: 'disconnected' }; - - case 'cancel_ok': - case 'cancel_no_op': - // The App's `cancel_pending` action ALWAYS optimistically lands us - // in `disconnected` before the bot's confirmation arrives. So a - // legitimate cancel-reply naturally finds state === 'disconnected' - // — accepting it then is a safe idempotent no-op transition. - // - // From ANY other state (awaiting_*, connected, logging_out, - // unknown), the cancel reply is stale: the user has either started - // a new flow (state already moved on) or never cancelled in this - // widget session at all. Letting it through would clobber an - // active flow — exactly the race the reviewer flagged: cancel + - // immediate re-login = late cancel_ok kicking awaiting_phone - // back to disconnected. - // - // (Out-of-band manual `!tg cancel` typed in chat-fallback while - // the widget shows an active form would also be ignored. That's - // accepted scope: we don't run a causality/epoch system, and the - // chat-fallback flow is an escape hatch, not a primary surface.) - if (state.kind !== 'disconnected') return state; - return { kind: 'disconnected' }; - - case 'login_in_progress': - // Surfaces when the user clicked Войти по номеру but the bridge - // already has a stale flow open. Form-level warning if a form is - // open; otherwise dropped so we don't manufacture a disconnected - // banner from nothing. - if (isFormState(state)) { - return { ...state, lastError: { kind: 'login_in_progress' } }; - } - return state; - - case 'max_logins': - // Should not fire for max_logins=1 operators when our UI hides - // login while connected. If it does fire, the user is in a race; - // surface the error on disconnected so they can logout first. - return { kind: 'disconnected', lastError: { kind: 'max_logins', limit: event.limit } }; - - case 'login_not_found': - // Logout target id was wrong. Treat as disconnected — bridge clearly - // doesn't know that login id any more. - return { kind: 'disconnected' }; - - case 'invalid_value': - // Bridge rejected our submitted phone/code/password (e.g. malformed - // phone). Keep the form open with an error; if no form is open, - // ignore so we don't pollute disconnected state. - if (!isFormState(state)) return state; - return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } }; - - case 'submit_failed': - // Telegram-side error (FloodWait, banned, etc.) leaked through - // bridgev2's commands layer. Hold the current form open so the user - // can retry; surface the verbatim Go error tail in the warning. - if (!isFormState(state)) return state; - return { ...state, lastError: { kind: 'submit_failed', reason: event.reason } }; - - case 'prepare_failed': - return { kind: 'disconnected', lastError: { kind: 'prepare_failed', reason: event.reason } }; - - case 'start_failed': - return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } }; - - case 'login_failed': - // bridgev2/commands/login.go:366 sends `Login failed: ` after - // the display-and-wait branch's `login.Wait()` returns. The error - // string we get here splits cleanly in two: - // - // 1. `context canceled` — fires whenever a `!tg cancel` tears - // down a running login flow. ALWAYS a no-op for our state: - // it's an echo of OUR cancel (or of an auto-cancel during a - // cancel-race recovery). If we transitioned to disconnected - // here, a stale «context canceled» from a previous flow - // could clobber a brand-new QR flow the user just started — - // observed in prod 2026-05-04 logs as a state-flapping loop. - // - // 2. anything else (most commonly `login process timed out` - // after the 10-min server-side LoginTimeout) — real failure - // of the live flow; route to disconnected with the warning. - if (event.reason === 'context canceled') return state; - if (state.kind === 'disconnected') return state; - return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } }; - - case 'flow_required': - case 'flow_invalid': - // We always send `login phone` so this shouldn't happen. If it does, - // the operator-config / bridge mismatch is loud enough to fail - // visibly on the disconnected screen. - return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: 'flow' } }; - - case 'unknown_command': - // Shouldn't happen — we only send commands the bridge knows. If it - // does, the operator-config / bridge image is mismatched; surface it - // loudly on the disconnected screen so the misconfig is visible. - return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; - - case 'qr_displayed': { - // `qrEventId` tracks the ORIGINAL bridge event — bridgev2 emits the - // QR as a single `m.image`, then on each token rotation (every ~30 s - // per Telegram MTProto QR-auth spec) edits the SAME event with - // `m.relates_to.rel_type=m.replace` + `m.relates_to.event_id=`. - // The eventual redaction also targets the original. So we only ever - // bind to the original id and repaint tgUrl on edits. - - // Defence-in-depth: an inbound `qr_displayed` MUST carry a non-empty - // event id (otherwise an adversarial bridge / spoofed event could - // land in the placeholder slot and never be dislodged because every - // subsequent check would also see empty ids). The parser produces - // `eventId: event.event_id` and the host driver rejects events with - // empty event_id at the sanitizer; this is a redundant guard. - if (event.eventId.length === 0) return state; - - // Initial QR for this flow — set both anchors. We accept from: - // * `unknown` — cold-start before list-logins resolves; - // * placeholder `awaiting_qr_scan{qrEventId=''}` set optimistically - // by `start_qr_login`; - // * `disconnected` — handles bridgev2's startup race. If the user - // clicks Cancel while bridge is still in `auth_key generation` - // (~2 s), the cancel arrives BEFORE the bridge's CommandState - // is registered, so it replies «No ongoing command» (cancel_no_op, - // state→disconnected via cancel_pending). Bridge then continues - // with the original login as if cancel never happened, and a - // few seconds later emits the m.image. Accepting from - // `disconnected` re-surfaces that QR so the user can either scan - // it or click Cancel again (this time the bridge has a real - // CommandState and the cancel will actually take). REJECTING - // here causes the user to be stuck on a disconnected screen - // while the bridge is happily hosting a 10-min QR-display-and- - // wait — bad UX, observed on production 2026-05-04. - if ( - state.kind === 'unknown' || - state.kind === 'disconnected' || - (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') - ) { - return { - kind: 'awaiting_qr_scan', - tgUrl: event.tgUrl, - qrEventId: event.eventId, - firstShownAt: - state.kind === 'awaiting_qr_scan' && state.firstShownAt - ? state.firstShownAt - : Date.now(), - }; - } - - if (state.kind !== 'awaiting_qr_scan') return state; - - // Rotation edit pointing at our original — repaint tgUrl, keep id. - if (event.replacesEventId === state.qrEventId) { - return { ...state, tgUrl: event.tgUrl }; - } - - // A fresh non-edit qr_displayed while we're already tracking one. - // Could be the bridge restarting the QR-login internally (rare). - // Adopt the new event as the new anchor — the old one will be - // either redacted or simply abandoned by the bridge. - if (!event.replacesEventId) { - return { - kind: 'awaiting_qr_scan', - tgUrl: event.tgUrl, - qrEventId: event.eventId, - firstShownAt: Date.now(), - }; - } - - // Edit pointing at something we don't track — ignore. Don't let - // foreign edits or stale-on-redacted events destabilise the panel. - return state; - } - - case 'qr_redacted': { - // Bridge cleaned up the QR after a successful scan. Held as - // `qr_verifying` until the next signal (twofa_required or - // login_success) lands. Only honour from awaiting_qr_scan with a - // matching event id — a redaction targeting some unrelated event - // (or a redaction arriving while we're already past the QR step) - // must not destabilise the current state. - if (state.kind !== 'awaiting_qr_scan') return state; - if (state.qrEventId !== event.redactsEventId) return state; - return { kind: 'qr_verifying' }; - } - - case 'unknown': - return state; - - default: { - // Exhaustiveness check — if a new LoginEvent kind is added without a - // case, TypeScript will flag this as a compile error. - const exhaustive: never = event; - return exhaustive; - } - } -}; - -// --- M12.5 hydrate-from-timeline ----------------------------------------- -// -// Why a separate reducer: the live `loginReducer` above intentionally rejects -// "out-of-thin-air" prompt events to defend against late-arriving replies -// from cancelled flows resurrecting forms. Those gates are correct for live -// traffic but useless for hydrate — from `unknown` they would drop every -// awaiting_* prompt and we'd render nothing. -// -// The hydrate reducer is permissive: it walks bot replies in chronological -// order and lets each one freely transition the state. We trust the timeline -// because (a) the sender is filtered to bootstrap.botMxid by the caller and -// (b) the events are durable bridge writes, not arbitrary user input. -// -// Hard scope: hydrate only ever returns one of awaiting_phone / awaiting_code -// / awaiting_password (with optional lastError) or null. Terminal-ish final -// states (login_success, logout_ok, cancel_*, not_logged_in, max_logins, -// login_not_found, prepare_failed, start_failed, flow_*, unknown_command) -// always fall back to null so App.tsx fires `list-logins` for authoritative -// reconciliation. - -// UX freshness guard, NOT bridge truth. Bridgev2's CommandState has no -// explicit TTL — it lives in User.CommandState until submit/cancel/restart -// clears it, so a "stale" prompt could in fact still be active at the bridge. -// This window is the UX-side judgement call: a 10-min-old code prompt is -// worth resurfacing (Telegram's own SMS code TTL ~5 min, plus margin), a -// day-old one is not. Tuned for code/password forms; phone is cheap enough -// that the same window applies. -const HYDRATE_FRESHNESS_MS = 10 * 60 * 1000; - -export type HydrateInput = { - ev: LoginEvent; - // origin_server_ts of the underlying m.notice. Used for the freshness - // check on the LAST significant pending prompt only. - ts: number; -}; - -type HydrateAccumulator = { - state: LoginState; - // Timestamp of the most recent event that contributed to a non-unknown, - // non-terminal pending state. Drives the freshness gate. - pendingTs: number | null; - // Once a terminal event lands, we stop honouring later pending prompts in - // the same scan — terminal collapses the chain and any subsequent pending - // prompt is a fresh flow that the live `list-logins` will reconcile. - terminated: boolean; -}; - -// Apply one event with permissive rules. Unlike the live reducer, every -// transition is allowed from any predecessor — we're rebuilding past truth, -// not protecting against late races. -const stepHydrate = ( - prevAcc: HydrateAccumulator, - input: HydrateInput -): HydrateAccumulator => { - const { ev, ts } = input; - - // After a terminal event (cancel_ok / logout_ok / login_success / …) we - // normally stop tracking — anything that follows is by definition a fresh - // flow that the live `list-logins` will reconcile. EXCEPT for two cases: - // if `awaiting_phone` shows up, that IS the bridgev2 signature of `!tg - // login phone` being re-issued; if `qr_displayed` shows up, that's the - // same pattern for `!tg login qr`. The user cancelled (or finished) and - // is now logging in again; the chain should resume tracking from the new - // start. Without this re-entry, sequences like - // [awaiting_code, cancel_ok, awaiting_phone, awaiting_code] - // (cancel-then-restart, mid-code) would return null and regress the very - // M12.5 bug we set out to fix. - if (prevAcc.terminated && ev.kind !== 'awaiting_phone' && ev.kind !== 'qr_displayed') { - return prevAcc; - } - // Restart-on-re-entry: clear the terminated bit AND any prior tracked - // state so the new flow's first event becomes the new anchor without - // inheriting the old QR's eventId. - const acc: HydrateAccumulator = prevAcc.terminated - ? { state: { kind: 'unknown' }, pendingTs: null, terminated: false } - : prevAcc; - - switch (ev.kind) { - case 'awaiting_phone': - return { state: { kind: 'awaiting_phone' }, pendingTs: ts, terminated: false }; - - case 'awaiting_code': - return { state: { kind: 'awaiting_code' }, pendingTs: ts, terminated: false }; - - case 'awaiting_password': - return { state: { kind: 'awaiting_password' }, pendingTs: ts, terminated: false }; - - case 'twofa_required': - return { state: { kind: 'awaiting_password' }, pendingTs: ts, terminated: false }; - - case 'qr_displayed': { - // Same anchor logic as the live reducer: qrEventId tracks the - // ORIGINAL event, edits repaint tgUrl. In hydrate we always start - // from `unknown` and walk past→present, so the original is the - // first qr_displayed without a `replacesEventId` we've already - // adopted. - if (acc.state.kind !== 'awaiting_qr_scan') { - return { - state: { - kind: 'awaiting_qr_scan', - tgUrl: ev.tgUrl, - qrEventId: ev.eventId, - firstShownAt: ts, - }, - pendingTs: ts, - terminated: false, - }; - } - if (ev.replacesEventId === acc.state.qrEventId) { - return { - state: { ...acc.state, tgUrl: ev.tgUrl }, - pendingTs: ts, - terminated: false, - }; - } - if (!ev.replacesEventId) { - return { - state: { - kind: 'awaiting_qr_scan', - tgUrl: ev.tgUrl, - qrEventId: ev.eventId, - firstShownAt: ts, - }, - pendingTs: ts, - terminated: false, - }; - } - return acc; - } - - case 'qr_redacted': { - // QR was consumed by a successful scan in the past. NOT terminal — - // a 2FA prompt or login_success typically follows in the same - // scan window, and reload-after-scan-but-before-2FA-submit MUST - // restore the password form (otherwise the user reloads, sees - // `disconnected`, list-logins replies «You're not logged in» - // because the bridge hasn't completed login yet, and the user - // has to restart the QR flow from scratch — losing the scan). - // Move into `qr_verifying` (interstitial) and keep the chain - // open so subsequent twofa_required / awaiting_password can lift - // us into the password form. - if (acc.state.kind !== 'awaiting_qr_scan') return acc; - if (acc.state.qrEventId !== ev.redactsEventId) return acc; - return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; - } - - case 'invalid_code': - return { - state: { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }, - pendingTs: ts, - terminated: false, - }; - - case 'wrong_password': - return { - state: { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }, - pendingTs: ts, - terminated: false, - }; - - case 'invalid_value': - // Form-scoped soft error: only surface if we're already on a form. - if (!isFormState(acc.state)) return acc; - return { - state: { ...acc.state, lastError: { kind: 'invalid_value', reason: ev.reason } }, - pendingTs: ts, - terminated: false, - }; - - case 'submit_failed': - if (!isFormState(acc.state)) return acc; - return { - state: { ...acc.state, lastError: { kind: 'submit_failed', reason: ev.reason } }, - pendingTs: ts, - terminated: false, - }; - - case 'login_failed': - // `context canceled` is an echo of a previous cancel — never a - // terminal signal for the chain we're hydrating, since the chain - // can immediately re-enter via a fresh `qr_displayed` / `awaiting_phone` - // for a new flow. Treat as a no-op so the chain keeps walking. - if (ev.reason === 'context canceled') return acc; - return { state: acc.state, pendingTs: null, terminated: true }; - - // Terminal events — collapse the chain. The state at this point is - // whatever-the-bot-confirmed-last; we don't care which, the caller - // returns null and lets `list-logins` reconcile. - case 'login_success': - case 'logout_ok': - case 'cancel_ok': - case 'cancel_no_op': - case 'not_logged_in': - case 'max_logins': - case 'login_not_found': - case 'prepare_failed': - case 'start_failed': - case 'flow_required': - case 'flow_invalid': - case 'unknown_command': - return { state: acc.state, pendingTs: null, terminated: true }; - - case 'logins_listed': - // A list-logins reply landed in history. Empty list means user wasn't - // logged in at that point; non-empty means they were. Both are - // terminal-ish for hydrate purposes — fall through to live - // reconciliation rather than guess at validity from cached snapshot. - return { state: acc.state, pendingTs: null, terminated: true }; - - case 'login_in_progress': - case 'unknown': - // Soft no-op for hydrate. login_in_progress is a live-flow warning - // that doesn't reflect persistent state; unknown is a wording-drift - // catch-all — neither should advance hydrate state. - return acc; - - default: { - const exhaustive: never = ev; - return exhaustive; - } - } -}; - -export const hydrateFromTimeline = ( - inputs: ReadonlyArray, - now: number = Date.now() -): HydrateRestoredState | null => { - const acc = inputs.reduce(stepHydrate, { - state: { kind: 'unknown' }, - pendingTs: null, - terminated: false, - }); - - if (acc.terminated) return null; - if (acc.pendingTs === null) return null; - if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; - if (acc.state.kind === 'qr_verifying') return acc.state; - if (!isFormState(acc.state)) return null; - return acc.state; -}; - -// --- DEV sanity assertions ------------------------------------------------ -// Mirrors the dialect-side runSanityChecks pattern — failure throws, dev -// overlay surfaces it on reload, production builds tree-shake the branch. - -if (import.meta.env.DEV) { - runHydrateSanity(); -} - -function runHydrateSanity(): void { - const t0 = 1_700_000_000_000; - const recent = (offset: number) => t0 + offset; - const now = t0 + 60 * 1000; // 1 minute after t0 - - const cases: Array<{ - name: string; - inputs: HydrateInput[]; - expected: LoginState | null; - nowOverride?: number; - }> = [ - { - name: 'empty timeline → null', - inputs: [], - expected: null, - }, - { - name: 'lone phone prompt → awaiting_phone', - inputs: [{ ev: { kind: 'awaiting_phone' }, ts: recent(0) }], - expected: { kind: 'awaiting_phone' }, - }, - { - name: 'phone → code → awaiting_code', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, - ], - expected: { kind: 'awaiting_code' }, - }, - { - name: '2fa pair → awaiting_password', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, - { ev: { kind: 'twofa_required' }, ts: recent(2000) }, - { ev: { kind: 'awaiting_password' }, ts: recent(2100) }, - ], - expected: { kind: 'awaiting_password' }, - }, - { - name: 'invalid_code retry → awaiting_code with error', - inputs: [ - { ev: { kind: 'awaiting_code' }, ts: recent(0) }, - { ev: { kind: 'invalid_code' }, ts: recent(1000) }, - { ev: { kind: 'awaiting_code' }, ts: recent(1100) }, - ], - expected: { kind: 'awaiting_code' }, // re-prompt clears the error — same as live reducer - }, - { - name: 'invalid_code as final event → awaiting_code with error', - inputs: [ - { ev: { kind: 'awaiting_code' }, ts: recent(0) }, - { ev: { kind: 'invalid_code' }, ts: recent(1000) }, - ], - expected: { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }, - }, - { - name: 'wrong_password as final event → awaiting_password with error', - inputs: [ - { ev: { kind: 'awaiting_password' }, ts: recent(0) }, - { ev: { kind: 'wrong_password' }, ts: recent(1000) }, - ], - expected: { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }, - }, - { - name: 'login_success after pending → null (let list-logins reconcile)', - inputs: [ - { ev: { kind: 'awaiting_password' }, ts: recent(0) }, - { - ev: { kind: 'login_success', handle: '@x', numericId: '1' }, - ts: recent(1000), - }, - ], - expected: null, - }, - { - name: 'cancel_ok after pending → null', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - ], - expected: null, - }, - { - name: 'not_logged_in alone → null', - inputs: [{ ev: { kind: 'not_logged_in' }, ts: recent(0) }], - expected: null, - }, - { - name: 'awaiting_phone after terminal → restart chain (resume tracking)', - inputs: [ - { ev: { kind: 'awaiting_code' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - { ev: { kind: 'awaiting_phone' }, ts: recent(2000) }, - ], - expected: { kind: 'awaiting_phone' }, - }, - { - name: 'cancel-then-restart-mid-code → awaiting_code (the reviewer-#11 regression)', - inputs: [ - { ev: { kind: 'awaiting_code' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - { ev: { kind: 'awaiting_phone' }, ts: recent(2000) }, - { ev: { kind: 'awaiting_code' }, ts: recent(3000) }, - ], - expected: { kind: 'awaiting_code' }, - }, - { - name: 'logout-then-relogin-mid-password → awaiting_password', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, - { ev: { kind: 'login_success', handle: '@x', numericId: '1' }, ts: recent(2000) }, - { ev: { kind: 'logout_ok' }, ts: recent(3000) }, - { ev: { kind: 'awaiting_phone' }, ts: recent(4000) }, - { ev: { kind: 'awaiting_code' }, ts: recent(5000) }, - { ev: { kind: 'twofa_required' }, ts: recent(6000) }, - { ev: { kind: 'awaiting_password' }, ts: recent(6100) }, - ], - expected: { kind: 'awaiting_password' }, - }, - { - name: 'stale prompt without preceding awaiting_phone → still terminated → null', - inputs: [ - { ev: { kind: 'awaiting_code' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - { ev: { kind: 'awaiting_code' }, ts: recent(2000) }, - ], - expected: null, - }, - { - name: 'pending too old → null (freshness guard)', - inputs: [{ ev: { kind: 'awaiting_code' }, ts: t0 - 30 * 60 * 1000 }], - expected: null, - }, - { - name: 'pending just inside window → state', - inputs: [{ ev: { kind: 'awaiting_code' }, ts: t0 - 9 * 60 * 1000 }], - expected: { kind: 'awaiting_code' }, - nowOverride: t0, - }, - { - name: 'submit_failed on pending → keeps form with warn', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'submit_failed', reason: 'PHONE_NUMBER_BANNED' }, ts: recent(1000) }, - ], - expected: { - kind: 'awaiting_phone', - lastError: { kind: 'submit_failed', reason: 'PHONE_NUMBER_BANNED' }, - }, - }, - { - name: 'login_in_progress alone → null (soft no-op, no pending state)', - inputs: [{ ev: { kind: 'login_in_progress' }, ts: recent(0) }], - expected: null, - }, - { - name: 'unknown alone → null', - inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }], - expected: null, - }, - // QR-login hydrate cases (M13) - { - name: 'lone qr_displayed → awaiting_qr_scan', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - tgUrl: 'tg://login?token=A', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr rotation edits → repaint url, keep original event id', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - { - ev: { - kind: 'qr_displayed', - tgUrl: 'tg://login?token=B', - eventId: '$qrEdit1', - replacesEventId: '$qrA', - }, - ts: recent(30000), - }, - { - ev: { - kind: 'qr_displayed', - tgUrl: 'tg://login?token=C', - eventId: '$qrEdit2', - replacesEventId: '$qrA', - }, - ts: recent(60000), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - tgUrl: 'tg://login?token=C', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr_redacted with mismatched target → ignored', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, - ], - expected: { - kind: 'awaiting_qr_scan', - tgUrl: 'tg://login?token=A', - qrEventId: '$qrA', - firstShownAt: recent(0), - }, - }, - { - name: 'qr scan → twofa pair → awaiting_password (mid-flow reload restores the password form)', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, - { ev: { kind: 'twofa_required' }, ts: recent(31000) }, - { ev: { kind: 'awaiting_password' }, ts: recent(31100) }, - ], - // qr_redacted moves into qr_verifying without terminating the - // chain — twofa_required + awaiting_password follow and lift the - // chain into the password form. Without this the user would - // reload mid-2FA and lose the QR scan progress. - expected: { kind: 'awaiting_password' }, - }, - { - name: 'qr scan → no follow-up → qr_verifying (reload during the 1 s gap)', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, - ], - // No twofa_required / login_success in this scan — bridge hasn't - // emitted the next signal yet. Restore qr_verifying so the user - // sees the «Проверяем вход…» pill on reload instead of a flash - // of disconnected. - expected: { kind: 'qr_verifying' }, - }, - { - name: 'qr scan → login_success → null (terminal — let list-logins reconcile)', - inputs: [ - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, - ts: recent(0), - }, - { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, - { - ev: { kind: 'login_success', handle: '@x', numericId: '1' }, - ts: recent(31000), - }, - ], - expected: null, - }, - { - name: 'cancel-then-qr-restart → awaiting_qr_scan', - inputs: [ - { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, - { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, - { - ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=Z', eventId: '$qrZ' }, - ts: recent(2000), - }, - ], - expected: { - kind: 'awaiting_qr_scan', - tgUrl: 'tg://login?token=Z', - qrEventId: '$qrZ', - firstShownAt: recent(2000), - }, - }, - ]; - - for (const c of cases) { - const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now); - if (!sameLoginState(actual, c.expected)) { - // eslint-disable-next-line no-console - console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected }); - throw new Error(`hydrate sanity failed: ${c.name}`); - } - } -} - -function sameLoginState(a: LoginState | null, b: LoginState | null): boolean { - if (a === null || b === null) return a === b; - return JSON.stringify(a) === JSON.stringify(b); -} diff --git a/apps/widget-telegram/src/styles.css b/apps/widget-telegram/src/styles.css index ccf47cb9..de4d01f2 100644 --- a/apps/widget-telegram/src/styles.css +++ b/apps/widget-telegram/src/styles.css @@ -93,35 +93,10 @@ body { padding-top: 4px; } -/* Section label — same dark-bg pill vocabulary as `.section-status` so the - * two pieces in the section-header row read as a matched pair (label - * pill + status pill). The pill chrome wraps the existing uppercase - * letter-spaced typography; chip is non-interactive, no cursor. */ -.section-label { - display: inline-flex; - align-items: center; - font-size: 13px; - line-height: 20px; - text-transform: uppercase; - letter-spacing: 1.4px; - font-weight: 600; - color: var(--muted); - background: var(--bg2); - border: 1px solid var(--divider); - border-radius: 8px; - padding: 8px 14px; - margin: 0 0 14px; - white-space: nowrap; - user-select: none; -} - /* Status pill — button-styled but intentionally non-interactive (no - * cursor:pointer, no hover). Replaces the section header for stateful - * sections (disconnected / connected / unknown / logging_out) — the - * pill itself carries the section's identity, so a separate - * `.section-label` would just duplicate the meaning. Same dark-bg - * vocabulary (--bg2 / divider border) as `.recovery-action` and the - * host hero's «О боте» chip. */ + * cursor:pointer, no hover). The pill carries the section's identity; + * same dark-bg vocabulary (--bg2 / divider border) as `.recovery-action` + * and the host hero's «О боте» chip. */ .section-status { display: inline-flex; align-items: center; @@ -242,7 +217,15 @@ body { text-align: left; font: inherit; color: inherit; - transition: border-color 0.12s, background 0.12s; + transition: border-color 0.12s, background 0.12s, transform 0.08s ease-out; +} + +/* Press feedback — :active fires on touch and mouse alike, and unlike + * :hover the WebView never leaves it stuck after the finger lifts. Scale + * is subtle on purpose: cards are wide, a deep squash looks broken. */ +.command-card:active:not(:disabled) { + transform: scale(0.985); + background: var(--surface); } /* Hover scoped to mouse-mode sessions only. Capacitor Android WebView @@ -354,87 +337,403 @@ body { } } -/* ── Transcript ──────────────────────────────────────────────────── */ +/* ── Press feedback for the smaller controls ──────────────────────── */ +/* Same rationale as .command-card:active above. Filled (violet/rose) + * buttons darken instead of swapping background so their accent reads + * as «pressed», not «replaced». */ +.recovery-action, +.icon-btn, +.btn-primary, +.btn-text, +.contact-action, +.probe-check, +.command-card-confirm-yes, +.command-card-confirm-no { + transition: transform 0.08s ease-out, background 0.12s, color 0.12s, border-color 0.12s, + filter 0.08s ease-out; +} +.recovery-action:active:not(:disabled), +.icon-btn:active:not(:disabled), +.btn-text:active:not(:disabled), +.probe-check:active:not(:disabled), +.command-card-confirm-no:active:not(:disabled) { + transform: scale(0.96); +} +.btn-primary:active:not(:disabled), +.contact-action:active:not(:disabled), +.command-card-confirm-yes:active:not(:disabled) { + transform: scale(0.96); + filter: brightness(0.85); +} -.transcript { +/* ── Icon button (compact action next to a status pill / search field) ── */ + +.icon-btn { + -webkit-appearance: none; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +:root[data-input='mouse'] .icon-btn:hover:not(:disabled) { + background: var(--surface); + color: var(--text); + border-color: var(--hairline); +} +.icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.icon-btn svg { + width: 16px; + height: 16px; +} +/* Square back button — same chrome as .icon-btn, bigger glyph: it's the + * primary way out of the contacts sub-screen, the arrow should read at a + * glance. */ +.icon-btn-back svg { + width: 22px; + height: 22px; +} +.icon-btn.spinning svg { + animation: command-card-spin 0.8s linear infinite; +} + +/* ── Spinner (inline in-flight indicator) ─────────────────────────── */ + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: command-card-spin 0.7s linear infinite; + flex-shrink: 0; + opacity: 0.8; +} + +/* ── Notice strip (inline action feedback — replaces the transcript) ── */ + +.notice-slot { + padding: 16px var(--section-pad-x) 0; +} + +.notice { + display: flex; + align-items: flex-start; + gap: 10px; + border-radius: 10px; + border: 1px solid var(--divider); + background: var(--bg2); + padding: 10px 14px; + font-size: 13px; + line-height: 19px; + color: var(--text); + margin-bottom: 12px; +} +.notice.error { + border-color: var(--rose); + color: var(--rose); + background: rgba(192, 142, 123, 0.08); +} +.notice.warn { + border-color: var(--amber); + color: var(--amber); + background: rgba(212, 184, 138, 0.08); +} +.notice.info { + border-color: var(--green); + color: var(--green); + background: rgba(125, 211, 168, 0.08); +} +.notice-body { + flex: 1; + min-width: 0; +} +.notice-dismiss { + background: transparent; + border: none; + color: inherit; + font: inherit; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 2px; + flex-shrink: 0; + opacity: 0.7; +} +.notice-dismiss:hover { + opacity: 1; +} + +/* ── Avatar (initials on a Dawn accent) ───────────────────────────── */ + +.avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + color: #0c0c0e; + font-weight: 700; + flex-shrink: 0; + user-select: none; + overflow: hidden; +} + +/* Photo layer (MSC4039 download). Sits on top of the initials — a missing + * or still-loading photo leaves the colored-letter fallback visible. */ +.avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── Contacts surface ─────────────────────────────────────────────── */ + +.contacts { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Search shell mirrors the mock's «Быстрый запуск» row: one integrated + * input strip with a leading glyph and a trailing refresh action. */ +.search-shell { + display: flex; + align-items: center; + gap: 10px; background: var(--bg2); border: 1px solid var(--divider); border-radius: 10px; - padding: 12px 14px; - font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; - font-size: 12.5px; - line-height: 1.55; - max-height: 360px; - overflow-y: auto; - /* Custom scrollbar styled into the dark palette. Native browser - * scrollbars (gray, system-themed) clash with the Dawn surface. */ - scrollbar-width: thin; - scrollbar-color: var(--surface2) transparent; + padding: 6px 14px; + transition: border-color 0.12s, box-shadow 0.12s; } - -.transcript::-webkit-scrollbar { - width: 8px; +.search-shell:focus-within { + border-color: var(--fleet); + box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18); } -.transcript::-webkit-scrollbar-track { - background: transparent; -} -.transcript::-webkit-scrollbar-thumb { - background: var(--surface2); - border-radius: 4px; - border: 2px solid var(--bg2); - background-clip: padding-box; -} -.transcript::-webkit-scrollbar-thumb:hover { - background: var(--surface); - border: 2px solid var(--bg2); - background-clip: padding-box; -} - -.transcript-line { - padding: 4px 0; - display: flex; - gap: 10px; - align-items: flex-start; - white-space: pre-wrap; - word-break: break-word; -} - -.transcript-line + .transcript-line { - border-top: 1px dashed var(--divider); -} - -.transcript-line .ts { - color: var(--faint); +.search-shell-icon { + display: inline-flex; + color: var(--muted); flex-shrink: 0; - font-variant-numeric: tabular-nums; +} +.search-shell-icon svg { + width: 16px; + height: 16px; +} +.search-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--text); + font: inherit; + font-size: 15px; + padding: 8px 0; +} +.search-input::placeholder { + color: var(--faint); } -.transcript-line .body { +/* Probe affordance — explicit «check on Telegram» action for identifier- + * shaped queries. Click-gated on purpose: resolve calls have no flood-wait + * retry on the bridge side. */ +.probe-check { + -webkit-appearance: none; + appearance: none; + display: flex; + align-items: center; + gap: 10px; + background: rgba(149, 128, 255, 0.08); + border: 1px dashed var(--fleet); + border-radius: 10px; + padding: 12px 14px; + font: inherit; + font-size: 14px; + color: var(--fleet-soft); + cursor: pointer; + text-align: left; + transition: background 0.12s; +} +:root[data-input='mouse'] .probe-check:hover:not(:disabled) { + background: rgba(149, 128, 255, 0.14); +} +.probe-check:disabled { + cursor: progress; +} +.probe-check svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.probe-result { + display: flex; + flex-direction: column; + gap: 8px; +} +.probe-result-label { + font-size: 13px; + color: var(--green); +} +.probe-result-label.missing { + color: var(--amber); +} +.probe-result .contact-row { + border-color: var(--fleet); +} + +.contact-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.contact-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 10px 12px; +} + +.contact-main { flex: 1; min-width: 0; } -.transcript-line.from-bot .body { +.contact-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.contact-name-text { + font-size: 14.5px; + font-weight: 600; color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.transcript-line.from-user .body { - color: var(--fleet-soft); -} - -.transcript-line.diag .body { +.contact-handle { + display: flex; + flex-wrap: wrap; + column-gap: 10px; + row-gap: 1px; + font-size: 12px; color: var(--muted); + font-family: ui-monospace, 'JetBrains Mono', monospace; + margin-top: 2px; + min-width: 0; +} +.contact-handle-part { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } -.transcript-line.error .body { +.contact-action { + -webkit-appearance: none; + appearance: none; + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--fleet); + color: #0c0c0e; + border: none; + border-radius: 7px; + padding: 8px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} +.contact-action.linked { + background: transparent; + color: var(--muted); + border: 1px solid var(--divider); + font-weight: 500; +} +:root[data-input='mouse'] .contact-action.linked:hover:not(:disabled) { + color: var(--text); + border-color: var(--hairline); + background: var(--surface); +} +.contact-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.contacts-placeholder { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13.5px; + padding: 28px 12px; + text-align: center; +} +.contacts-error-text { color: var(--rose); } -.transcript-empty { - color: var(--faint); - text-align: center; - padding: 16px 0; - font-style: italic; +/* ── Account tab ──────────────────────────────────────────────────── */ + +.account-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--divider); + border-radius: 10px; + padding: 14px 16px; +} + +.account-main { + flex: 1; + min-width: 0; +} + +.account-name { + font-size: 16px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-handles { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 3px; + font-size: 12.5px; + color: var(--muted); + font-family: ui-monospace, 'JetBrains Mono', monospace; } /* Destructive card — keeps the red name to mark «Выйти из Telegram» as a @@ -559,6 +858,9 @@ body { .auth-input:hover:not(:focus):not(:disabled) { border-color: rgba(255, 255, 255, 0.16); } +[data-theme='light'] .auth-input:hover:not(:focus):not(:disabled) { + border-color: rgba(0, 0, 0, 0.22); +} .auth-input:focus { border-color: var(--fleet); /* Stronger ring than border-color alone — matches Dawn's emphasis on @@ -781,6 +1083,11 @@ body { * paste-on-paper. */ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32); } +[data-theme='light'] .auth-card-qr-frame { + /* The dark-mode edge highlight is invisible on a light surface; a plain + * soft drop shadow separates the white plate instead. */ + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.14); +} /* Placeholder while we wait for the bridge's first qr_displayed event. * Same visual vocabulary as `.section-status.checking`: amber dot + muted @@ -859,16 +1166,59 @@ body { .auth-card-qr-placeholder { padding: 80px 12px; } -} -/* ── Linkified transcript bodies ─────────────────────────────────── */ + /* Contact rows: tighter chrome, and the action button stays inline (it's + * short — «Открыть чат» worst case) rather than wrapping full-width. */ + .contact-row { + padding: 9px 10px; + gap: 10px; + } + .contact-action { + padding: 8px 11px; + font-size: 12.5px; + } + /* Narrow viewports: @ник и телефон друг под другом вместо обрезанной + * одной строки — на телефоне рядом с кнопкой им не хватает ширины. */ + .contact-handle { + flex-direction: column; + } -.transcript-line a { - color: var(--fleet-soft); - text-decoration: underline; -} -.transcript-line a:hover { - color: var(--text); + /* Header row on phones: the status pill grows to fill the row at the + * search-field height (~48px), and the square icon buttons (refresh / + * back) match it, so the trio lines up edge-to-edge with the search + * shell below. Desktop keeps the compact content-sized pill. */ + .section-recovery-row { + flex-wrap: nowrap; + } + .section-recovery-row > .section-status { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + padding: 13px 16px; + } + .section-recovery-row > .icon-btn { + width: 48px; + height: 48px; + border-radius: 10px; + } + .section-recovery-row > .icon-btn svg { + width: 18px; + height: 18px; + } + .section-recovery-row > .icon-btn.icon-btn-back svg { + width: 25px; + height: 25px; + } + /* Labeled row actions (Отмена / Повторить on the transient screens) + * match the taller pill so the row reads as one piece. */ + .section-recovery-row > .recovery-action { + padding: 13px 16px; + border-radius: 10px; + white-space: nowrap; + flex-shrink: 0; + } } /* ── Hint text ───────────────────────────────────────────────────── */ @@ -927,6 +1277,9 @@ body { * reassuring tone of the body copy itself. */ animation: about-fade 0.15s ease-out; } +[data-theme='light'] .about-overlay { + background: rgba(26, 26, 29, 0.36); +} @keyframes about-fade { from { diff --git a/apps/widget-telegram/src/ui.tsx b/apps/widget-telegram/src/ui.tsx new file mode 100644 index 00000000..9f30399f --- /dev/null +++ b/apps/widget-telegram/src/ui.tsx @@ -0,0 +1,220 @@ +// Shared visual vocabulary: inline SVG icons (stroke-only, currentColor so +// they inherit the Dawn palette), the initials avatar, and the command-card +// building blocks reused across the login and contacts surfaces. +// +// Visual canon: «Боты» mockup at +// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx +// (BotsDesktop) — Dawn palette, fleet-violet accent, letter avatars, +// friendly cards, no terminal styling. + +import type { ComponentChildren } from 'preact'; + +export const RefreshIcon = () => ( + +); + +export const InfoIcon = () => ( + +); + +export const PhoneIcon = () => ( + +); + +export const QrIcon = () => ( + +); + +export const SearchIcon = () => ( + +); + +export const BackIcon = () => ( + +); + +export const UserIcon = () => ( + +); + +export const LogoutIcon = () => ( + +); + +// Eye + eye-with-slash for the password reveal toggle. SVG paths copied +// verbatim from folds `Icons.Eye(false)` / `Icons.EyeBlind(false)` — the +// unfilled variants Vojo's main auth uses. Importing folds into the widget +// bundle would pull the whole component library, so we inline the geometry. +export const EyeIcon = () => ( + +); + +export const EyeBlindIcon = () => ( + +); + +// --- Initials avatar -------------------------------------------------------- + +// Dawn accent rotation (same hues the design mock assigns to bot list +// entries). Hash by codepoints so a contact keeps its color across loads. +const AVATAR_COLORS = ['#9580ff', '#7ab6d9', '#d4b88a', '#7dd3a8', '#c08e7b', '#a59cff']; + +const hashString = (s: string): number => { + let h = 0; + for (let i = 0; i < s.length; i += 1) { + h = (h * 31 + s.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise + } + return Math.abs(h); +}; + +export const avatarColor = (key: string): string => + AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length]; + +export const initialsOf = (name: string | undefined, fallback = '?'): string => { + const trimmed = (name ?? '').trim(); + if (!trimmed) return fallback; + const parts = trimmed.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return trimmed.slice(0, 1).toUpperCase(); +}; + +type AvatarProps = { name?: string; colorKey: string; size?: number; src?: string | null }; + +// Initials always render underneath; the photo (when the MSC4039 download +// resolved) layers on top, so a slow or failed download degrades to the +// colored-letter look instead of an empty box. +export const Avatar = ({ name, colorKey, size = 38, src }: AvatarProps) => ( + +); + +// --- Command card ----------------------------------------------------------- + +type CommandCardProps = { + icon: ComponentChildren; + name: string; + desc: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; + spinning?: boolean; +}; + +export const CommandCard = ({ + icon, + name, + desc, + onClick, + danger, + disabled, + spinning, +}: CommandCardProps) => ( + +); + +// --- Status / notice -------------------------------------------------------- + +type StatusPillProps = { + tone: 'connected' | 'disconnected' | 'checking'; + children: ComponentChildren; +}; + +export const StatusPill = ({ tone, children }: StatusPillProps) => ( + + + {children} + +); + +type NoticeProps = { + tone: 'error' | 'warn' | 'info'; + children: ComponentChildren; + onDismiss?: () => void; +}; + +// Inline notice strip — the replacement for the old transcript pane. One +// line of feedback right where the action happened, dismissable, no log. +export const Notice = ({ tone, children, onDismiss }: NoticeProps) => ( +
+ {children} + {onDismiss ? ( + + ) : null} +
+); + +export const Spinner = () =>
` clicks — the WebView - // doesn't have a multi-window concept, and the host's global - // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks - // inside the host document, not inside the iframe (cross-origin - // events don't bubble across the frame boundary). The widget posts - // this message instead; the host calls `openExternalUrl(url)` which - // routes to `Browser.open` on native and `window.open` on web. + // ClientWidgetApi's request/response machinery. Needed because + // cross-origin iframes inside Capacitor's Android WebView silently drop + // `` clicks. public openExternalUrl(url: string): void { + this.postSideChannel('open-external-url', { url }); + } + + // Ask the host to navigate to a Matrix room. The host validates the URL + // through `parseMatrixToRoom` and picks the destination surface (bridge + // space in Каналы / /direct/) — see BotWidgetMount.handleOpenMatrixToRoom. + public openMatrixRoom(roomId: string): void { + this.postSideChannel('open-matrix-to', { + url: `https://matrix.to/#/${encodeURIComponent(roomId)}`, + }); + } + + private postSideChannel(action: string, data: Record): void { window.parent.postMessage( - { - api: 'io.vojo.bot-widget', - action: 'open-external-url', - data: { url }, - }, + { api: 'io.vojo.bot-widget', action, data }, this.bootstrap.parentOrigin ); } - // Always prefix outbound commands with ` ` (trailing space — - // bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both - // the management room and any other room the bot may have been moved to. - // Form-field submissions (phone / code / password) 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); - } - - // M12.5 timeline-resume probe. Action name is MSC2876 (`read_events`); the - // capability is MSC2762 timeline (already requested at construction). We - // pass `room_ids: [bootstrap.roomId]` explicitly so the host's - // ClientWidgetApi takes the modern code path that calls our driver's - // `readRoomTimeline` (single-room cap-checked) rather than the deprecated - // `readRoomEvents` fallback. Driver returns events newest-first; reversing - // to chronological order is the caller's job. - // - // `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR - // post-scan cleanup events. `msgtype` is honoured only for m.room.message - // (matches the driver's `readRoomTimeline` semantics). - public async readTimeline(opts: { - limit: number; - type?: 'm.room.message' | 'm.room.redaction'; - msgtype?: 'm.text' | 'm.notice' | 'm.image'; - }): Promise { - const data: Record = { - type: opts.type ?? 'm.room.message', - limit: opts.limit, - room_ids: [this.bootstrap.roomId], - }; - if (opts.msgtype !== undefined) data.msgtype = opts.msgtype; - const res = await this.fromWidget('org.matrix.msc2876.read_events', data); - return (res.events as RoomEvent[] | undefined) ?? []; - } - private emit( event: K, ...args: Parameters @@ -190,11 +200,10 @@ export class WidgetApi { if (ev.origin !== this.bootstrap.parentOrigin) return; // Source-window guard: every legit widget API message comes from the // host window that embedded our iframe — i.e. window.parent. A foreign - // tab/frame on the same origin (think browser extension content - // script, popup, or sibling iframe) could otherwise post a forged - // message that passes the origin check. We only accept messages - // whose `source` is literally `window.parent`. The `widgetId` check - // a few lines down is a soft filter; this is the hard one. + // tab/frame on the same origin (browser extension content script, + // popup, sibling iframe) could otherwise post a forged message that + // passes the origin check. The `widgetId` check below is a soft + // filter; this is the hard one. if (ev.source !== window.parent) return; const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined; if (!msg || typeof msg !== 'object') return; @@ -230,7 +239,11 @@ export class WidgetApi { if (!msg.requestId || !msg.action) return; switch (msg.action) { case 'capabilities': { - this.replyTo(msg, { capabilities: this.capabilities }); + // No MSC2762 capabilities — no timeline reads, no message sends. The + // only capability the HTTP-transport widget needs is MSC4039 media + // download, used for contact/profile avatar thumbnails (media on the + // homeserver is authenticated and the widget holds no Matrix token). + this.replyTo(msg, { capabilities: ['org.matrix.msc4039.download_file'] }); return; } case 'notify_capabilities': { @@ -242,7 +255,7 @@ export class WidgetApi { return; } case 'supported_api_versions': { - this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] }); + this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc1960'] }); return; } case 'theme_change': { @@ -252,29 +265,22 @@ export class WidgetApi { this.replyTo(msg, {}); return; } - case 'send_event': { - // Live event push from host. Forward `m.room.message` (carries the - // bot's notices / errors / `m.image` QR-login broadcasts) AND - // `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver - // `sanitizeBotWidgetRedactionEvent`). State events (m.room.member) - // also arrive on this channel — we still ignore them here. - const data = msg.data as Partial | undefined; - if ( - data && - data.event_id && - (data.type === 'm.room.message' || data.type === 'm.room.redaction') - ) { - this.emit('liveEvent', data as RoomEvent); + case 'openid_credentials': { + // Phase-2 MSC1960 delivery after a `state: "request"` reply. + const originalId = msg.data?.original_request_id; + if (typeof originalId === 'string') { + const waiter = this.pendingOpenId.get(originalId); + if (waiter) { + this.pendingOpenId.delete(originalId); + window.clearTimeout(waiter.timer); + const creds = msg.data.state === 'allowed' ? parseOpenIdCredentials(msg.data) : null; + if (creds) waiter.resolve(creds); + else waiter.reject(new Error('OpenID request blocked by host')); + } } this.replyTo(msg, {}); return; } - case 'update_state': { - // Initial room state push from host (m.room.member members). - // M11 ignores this; 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, {}); @@ -284,10 +290,10 @@ export class WidgetApi { private fromWidget( action: string, - data: Record + data: Record, + requestId = this.nextRequestId() ): Promise> { return new Promise((resolve, reject) => { - const requestId = this.nextRequestId(); this.pending.set(requestId, { resolve, reject }); this.postToHost({ api: 'fromWidget', @@ -305,22 +311,3 @@ export class WidgetApi { }); } } - -// Capability set must match docs/plans/bots_tab.md (Phase 3 contract) and -// the host's BotWidgetDriver.getBotWidgetCapabilities. Anything else is -// silently dropped by the host's validateCapabilities — keep this aligned. -// -// `m.image` and `m.room.redaction` are the QR-login additions (M13). The -// host sanitizer for `m.image` strips `url` / `file` / `info`, leaving only -// `body` (the bridge encodes `tg://login?token=...` there) plus -// `m.relates_to` / `m.new_content` for QR rotation edits. Redactions -// signal that the QR was consumed by a successful scan. -export const buildCapabilities = (roomId: string): Capability[] => [ - `org.matrix.msc2762.timeline:${roomId}`, - 'org.matrix.msc2762.send.event:m.room.message#m.text', - 'org.matrix.msc2762.receive.event:m.room.message#m.text', - 'org.matrix.msc2762.receive.event:m.room.message#m.notice', - 'org.matrix.msc2762.receive.event:m.room.message#m.image', - 'org.matrix.msc2762.receive.event:m.room.redaction', - 'org.matrix.msc2762.receive.state_event:m.room.member', -]; diff --git a/config.json b/config.json index ecc8c242..0711e5b8 100644 --- a/config.json +++ b/config.json @@ -29,7 +29,9 @@ "name": "Telegram", "experience": { "type": "matrix-widget", - "url": "https://widgets.vojo.chat/telegram/index.html" + "url": "https://widgets.vojo.chat/telegram/index.html", + "provisioningUrl": "https://vojo.chat/_provision/telegram", + "capabilities": ["vojo.openid"] } }, { diff --git a/src/app/features/bots/BotWidgetDriver.ts b/src/app/features/bots/BotWidgetDriver.ts index f6d2d212..ed8e75ee 100644 --- a/src/app/features/bots/BotWidgetDriver.ts +++ b/src/app/features/bots/BotWidgetDriver.ts @@ -5,6 +5,7 @@ import { type IRoomEvent, type ISendEventDetails, type SimpleObservable, + MatrixCapabilities, WidgetDriver, WidgetEventCapability, OpenIDRequestState, @@ -21,7 +22,7 @@ import { } from 'matrix-js-sdk'; import { Membership } from '../../../types/matrix/room'; import { isPortalRoomForOtherBridge } from './room'; -import type { BotPreset } from './catalog'; +import { BOT_CAP_OPENID, isWidgetExperience, type BotPreset } from './catalog'; const BOT_WIDGET_TIMELINE_LIMIT = 100; @@ -190,6 +191,11 @@ export const getBotWidgetCapabilities = (roomId: string): Set => // arrived (which is not guaranteed in the no-2FA branch). WidgetEventCapability.forRoomEvent(EventDirection.Receive, EventType.RoomRedaction).raw, WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw, + // MSC4039 media download — the telegram widget renders contact/profile + // avatars from bridge-ghost mxc URIs, and media on the homeserver is + // authenticated (the widget itself holds no Matrix token). The driver's + // downloadFile below serves thumbnails only. + MatrixCapabilities.MSC4039DownloadFile, ]); export class BotWidgetDriver extends WidgetDriver { @@ -318,9 +324,54 @@ export class BotWidgetDriver extends WidgetDriver { return this.getSafeRoom() ? [this.roomId] : []; } - // eslint-disable-next-line class-methods-use-this + // MSC1960 OpenID credentials for first-party bridge widgets. Gated on the + // declarative `vojo.openid` opt-in in config.json (trusted root — the widget + // cannot grant itself the capability) plus the same room-safety invariant as + // every other driver surface. The OpenID token is identity-proof only + // (federation `/openid/userinfo`), not a Matrix access token: the widget + // exchanges it with the bridge provisioning API (`Bearer openid:`), + // which validates it against the homeserver. Auto-granted without a user + // prompt because the widget origin is operator-allowlisted and the token + // grants no homeserver read/write power. public askOpenID(observer: SimpleObservable): void { - observer.update({ state: OpenIDRequestState.Blocked }); + const { experience } = this.preset; + if ( + !isWidgetExperience(experience) || + !experience.capabilities.includes(BOT_CAP_OPENID) || + !this.getSafeRoom() + ) { + observer.update({ state: OpenIDRequestState.Blocked }); + return; + } + this.mx.getOpenIdToken().then( + (token) => observer.update({ state: OpenIDRequestState.Allowed, token }), + (error) => { + // eslint-disable-next-line no-console + console.warn('[BotWidget] getOpenIdToken failed', error); + observer.update({ state: OpenIDRequestState.Blocked }); + } + ); + } + + // MSC4039 download, scoped to the bot-widget avatar use case: only mxc + // URIs, and the host substitutes a server-side 96px crop thumbnail for the + // original — the widget renders 38-48px avatars, so shipping full-size + // profile photos through postMessage would be pure waste. Media is fetched + // with the host's access token (authenticated media, MSC3916); the bytes + // cross the postMessage boundary as a Blob, the token never does. + public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { + if (!this.getSafeRoom()) throw new Error('Bot widget room is not available'); + if (typeof contentUri !== 'string' || !contentUri.startsWith('mxc://')) { + throw new Error('Bot widget can only download mxc:// media'); + } + const url = this.mx.mxcUrlToHttp(contentUri, 96, 96, 'crop', false, true, true); + if (!url) throw new Error('Failed to resolve media URL'); + const token = this.mx.getAccessToken(); + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!res.ok) throw new Error(`Media download failed: HTTP ${res.status}`); + return { file: await res.blob() }; } // eslint-disable-next-line class-methods-use-this diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 55c17632..897f1204 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -77,6 +77,12 @@ const getBotWidgetUrl = ( // `onWidgetMessage` (the host re-reads `preset.experience.capabilities` from // the trusted config). Empty CSV when no caps; the widget reads ''→[] (F19). url.searchParams.set('capabilities', preset.experience.capabilities.join(',')); + // Bridge provisioning API base URL (validated in catalog.ts). The widget + // uses it as its fetch target, authenticating with MSC1960 OpenID creds it + // requests through the widget API — see BotWidgetDriver.askOpenID. + if (preset.experience.provisioningUrl) { + url.searchParams.set('provisioningUrl', preset.experience.provisioningUrl); + } url.searchParams.set('theme', theme.kind); url.searchParams.set('clientLanguage', language); url.searchParams.set('baseUrl', mx.baseUrl); diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 8b9aee67..4ecd2221 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Room, SyncState } from 'matrix-js-sdk'; +import { ClientEvent, EventType, MatrixClient, Room, RoomEvent, SyncState } from 'matrix-js-sdk'; import type { BotPreset } from './catalog'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSyncState } from '../../hooks/useSyncState'; @@ -9,8 +9,14 @@ import { getCanonicalAliasRoomId, isRoomAlias, } from '../../utils/matrix'; -import { getChannelsSpacePath } from '../../pages/pathUtils'; +import { + getChannelsRoomPath, + getChannelsSpacePath, + getDirectRoomPath, +} from '../../pages/pathUtils'; import type { MatrixToRoom } from '../../plugins/matrix-to'; +import { Membership } from '../../../types/matrix/room'; +import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { BotAddToChatPicker } from './BotAddToChatPicker'; import * as css from './BotWidgetMount.css'; @@ -31,6 +37,114 @@ const LOADING_BAR_FADE_IN_DELAY_MS = 100; // reduced-motion path skips this branch entirely. const LOADING_BAR_HIDE_FALLBACK_MS = 2000; +// Freshly created bridge DM portals reach the client with a sync delay: the +// widget's create_dm call returns the room id from the bridge BEFORE the +// invite/join lands in /sync. Navigating immediately would render a +// room-not-found surface, so we wait for the room to appear (and ideally for +// auto-direct-sync to join it) up to this deadline, then navigate regardless — +// by that point the invite view is a reasonable fallback. +const OPEN_ROOM_SYNC_DEADLINE_MS = 15_000; + +const waitForKnownRoom = (mx: MatrixClient, roomId: string, timeoutMs: number): Promise => + new Promise((resolve) => { + const isReady = () => { + const room = mx.getRoom(roomId); + return !!room && room.getMyMembership() === Membership.Join; + }; + if (isReady()) { + resolve(); + return; + } + let settled = false; + let cleanup = () => {}; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const check = () => { + if (isReady()) finish(); + }; + const onRoom = (room: Room) => { + if (room.roomId === roomId) check(); + }; + const onMembership = (room: Room) => { + if (room.roomId === roomId) check(); + }; + const timer = window.setTimeout(finish, timeoutMs); + mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.MyMembership, onMembership); + cleanup = () => { + window.clearTimeout(timer); + mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.MyMembership, onMembership); + }; + // Close the gap between the sync check above and listener attachment. + check(); + }); + +// Bridge portals are organized under the bridge's space (mautrix personal +// filtering space — the «Telegram» space in the Каналы tab), and the bridge +// adds the m.space.child link a beat AFTER the room itself syncs. Give that +// link a short window so a freshly created portal routes into its space on +// the first try instead of falling back to /direct/. +const PARENT_SPACE_WAIT_MS = 5_000; +const PARENT_SPACE_POLL_MS = 400; + +// A joined space that lists this room as its child. Plain poll — the +// roomToParents map is cheap to rebuild and this runs once per open-room +// verb, not per render. +const findParentSpace = (mx: MatrixClient, roomId: string): string | undefined => { + const parents = getRoomToParents(mx).get(roomId); + if (!parents) return undefined; + return [...parents].find((spaceId) => { + const space = mx.getRoom(spaceId); + return !!space && isSpace(space) && space.getMyMembership() === Membership.Join; + }); +}; + +const waitForParentSpace = ( + mx: MatrixClient, + roomId: string, + isCancelled: () => boolean +): Promise => + new Promise((resolve) => { + const immediate = findParentSpace(mx, roomId); + if (immediate) { + resolve(immediate); + return; + } + const deadline = Date.now() + PARENT_SPACE_WAIT_MS; + const timer = window.setInterval(() => { + // Stop polling early when the navigation was superseded or the mount + // is gone — the caller's .then() would discard the result anyway. + if (isCancelled()) { + window.clearInterval(timer); + resolve(undefined); + return; + } + const spaceId = findParentSpace(mx, roomId); + if (spaceId || Date.now() >= deadline) { + window.clearInterval(timer); + resolve(spaceId); + } + }, PARENT_SPACE_POLL_MS); + }); + +// Fallback classification when the room has no parent space: m.direct tag or +// the strict two-member gate (same `isOneOnOneRoom` rule the Direct list +// uses) → /direct/, everything else → channels. +const isDirectRoomId = (mx: MatrixClient, roomId: string): boolean => { + const content = mx.getAccountData(EventType.Direct)?.getContent() ?? {}; + const tagged = Object.values(content).some( + (rooms) => Array.isArray(rooms) && (rooms as string[]).includes(roomId) + ); + if (tagged) return true; + const room = mx.getRoom(roomId); + return !!room && isOneOnOneRoom(room); +}; + // Renders the iframe-mount target and wires up `useBotWidgetEmbed` to // drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell // refactor — «Host» was overloaded with BotExperienceHost (page-level @@ -46,6 +160,19 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const navigate = useNavigate(); const mx = useMatrixClient(); + // Deferred open-room navigations can resolve up to OPEN_ROOM_SYNC_DEADLINE_MS + // after the verb — by then the user may have navigated elsewhere (unmounting + // this mount) or fired a newer open-room. Track liveness + a latest-wins + // sequence so a stale wait never yanks the router. + const aliveRef = useRef(true); + const openRoomSeq = useRef(0); + useEffect( + () => () => { + aliveRef.current = false; + }, + [] + ); + // Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any // widget that posts `{action: 'open-matrix-to', data: {url}}` on the // `io.vojo.bot-widget` side-channel reaches this. The embed has already @@ -62,8 +189,41 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const idOrAlias = isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias : roomIdOrAlias; - const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias); - navigate(getChannelsSpacePath(canonical)); + if (isRoomAlias(idOrAlias)) { + // Unresolvable alias — no room id to wait on or classify; the + // channels path handles alias resolution itself. + navigate(getChannelsSpacePath(idOrAlias)); + return; + } + // Freshly created DM portals (widget create_dm) land in /sync after the + // bridge replies, so give the room a window to appear and get auto-joined + // before deciding the destination. Destination priority mirrors where + // the room actually LIVES in the UI: + // 1. child of a joined space (bridge portals under the Telegram + // space) → that space's room path in the Каналы tab; + // 2. m.direct / plain 1:1 → /direct/; + // 3. everything else → channels. + openRoomSeq.current += 1; + const seq = openRoomSeq.current; + waitForKnownRoom(mx, idOrAlias, OPEN_ROOM_SYNC_DEADLINE_MS) + .then(() => { + if (!aliveRef.current || seq !== openRoomSeq.current) return undefined; + return waitForParentSpace( + mx, + idOrAlias, + () => !aliveRef.current || seq !== openRoomSeq.current + ); + }) + .then((spaceId) => { + if (!aliveRef.current || seq !== openRoomSeq.current) return; + if (spaceId) { + navigate(getChannelsRoomPath(spaceId, idOrAlias)); + } else if (isDirectRoomId(mx, idOrAlias)) { + navigate(getDirectRoomPath(idOrAlias)); + } else { + navigate(getChannelsSpacePath(getCanonicalAliasOrRoomId(mx, idOrAlias))); + } + }); }, [mx, navigate] ); diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts index 3c91db7d..3822a518 100644 --- a/src/app/features/bots/catalog.ts +++ b/src/app/features/bots/catalog.ts @@ -20,6 +20,12 @@ export type BotWidgetExperience = { * the runtime gate `capabilities.includes(...)` is well-defined — see * `normalizeBotCaps`. Filtered against `KNOWN_BOT_CAPS` at load; absent ⇒ `[]`. */ capabilities: string[]; + /** Base URL of the bridge's provisioning HTTP API (bridgev2 `/_matrix/provision` + * mount behind the reverse proxy). Forwarded to the widget as a URL param so it + * can drive the bridge over JSON instead of bot text commands. Absent when the + * operator hasn't exposed the API; the widget then renders a config-required + * notice. Validated by `normalizeProvisioningUrl` (https-only in prod). */ + provisioningUrl?: string; }; export type AiChatExperience = { @@ -38,13 +44,20 @@ export const isAiChatExperience = ( experience: BotExperience | undefined ): experience is AiChatExperience => experience?.type === 'ai-chat'; -/** The only elevated side-channel verb today: lets the widget ask the host to - * invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The - * widget never supplies the room or the mxid. Privilege originates from the - * trusted config.json opt-in, NOT from the widget claiming it. */ +/** Elevated side-channel verb: lets the widget ask the host to invite the bot + * (`preset.mxid`, host-substituted) into a user-picked room. The widget never + * supplies the room or the mxid. Privilege originates from the trusted + * config.json opt-in, NOT from the widget claiming it. */ export const BOT_CAP_ADD_TO_CHAT = 'vojo.add_to_chat'; -const KNOWN_BOT_CAPS: ReadonlySet = new Set([BOT_CAP_ADD_TO_CHAT]); +/** Grants the widget MSC1960 OpenID credentials (BotWidgetDriver.askOpenID). + * The OpenID token only proves «this is @user:vojo.chat» to third parties via + * the federation API — it is NOT a Matrix access token and cannot read or send + * anything on the homeserver. Widgets use it as the bearer credential for the + * bridge provisioning API (`openid:` auth in bridgev2 AuthMiddleware). */ +export const BOT_CAP_OPENID = 'vojo.openid'; + +const KNOWN_BOT_CAPS: ReadonlySet = new Set([BOT_CAP_ADD_TO_CHAT, BOT_CAP_OPENID]); export type BotPreset = { /** Stable URL slug — `/bots/`. Never reuse across bots. */ @@ -107,6 +120,29 @@ const normalizeBotCaps = (raw: unknown): string[] => { ]; }; +// Validate the operator-supplied provisioning API base URL. Unlike the widget +// URL this is NOT loaded into an iframe — it's a fetch target inside the +// widget — so there is no origin allowlist; https (or localhost http in dev) +// with no embedded credentials is the bar. Invalid values drop to `undefined` +// (the widget renders its config-required notice) instead of rejecting the +// whole experience, so a typo here can't take the login UI down with it. +// Trailing slashes are stripped so the widget can join paths naively. +const normalizeProvisioningUrl = (raw: unknown): string | undefined => { + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed); + if (parsed.username || parsed.password) return undefined; + const devLocalhost = + import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost'; + if (parsed.protocol !== 'https:' && !devLocalhost) return undefined; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return undefined; + } +}; + const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset => typeof preset?.id === 'string' && BOT_ID_RE.test(preset.id) && @@ -135,6 +171,8 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie // literal below (no spread — F2). const capabilities = normalizeBotCaps(experience?.capabilities); + const provisioningUrl = normalizeProvisioningUrl(experience?.provisioningUrl); + if (url.startsWith('/')) { // Resolve once so `/widgets/../admin` collapses before the prefix check — // a relative `/widgets/...` survives `new URL(url, base)` only if it does @@ -162,6 +200,7 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie url: `${resolved.pathname}${resolved.search}${resolved.hash}`, commandPrefix, capabilities, + provisioningUrl, }; } catch { return undefined; @@ -177,12 +216,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie // collapses to a literal `false`), so it never relaxes the prod validator. if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') { if (parsed.username || parsed.password) return undefined; - return { type, url: parsed.toString(), commandPrefix, capabilities }; + return { type, url: parsed.toString(), commandPrefix, capabilities, provisioningUrl }; } if (parsed.protocol !== 'https:') return undefined; if (parsed.username || parsed.password) return undefined; if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined; - return { type, url: parsed.toString(), commandPrefix, capabilities }; + return { type, url: parsed.toString(), commandPrefix, capabilities, provisioningUrl }; } catch { return undefined; } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 426a2532..af270374 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -27,6 +27,13 @@ export type BotConfig = { /** Widget iframe URL. Required for `'matrix-widget'`; absent for `'ai-chat'`. */ url?: string; commandPrefix?: string; + /** Base URL of the bridge's provisioning HTTP API as exposed by the + * reverse proxy (e.g. `https://vojo.chat/_provision/telegram`). When + * present (and valid per catalog.ts `normalizeProvisioningUrl`) it is + * forwarded to the widget, which then drives the bridge over JSON + * endpoints instead of bot text commands. Requires the `vojo.openid` + * capability so the widget can authenticate. */ + provisioningUrl?: string; /** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`). * Validated against an allowlist at load (catalog.ts `normalizeBotCaps`); * unknown entries are dropped, absent ⇒ `[]`. config.json is a trusted,