feat(telegram): rewrite the widget onto the bridge provisioning API with OpenID auth, a contacts picker, and MSC4039 avatars

This commit is contained in:
heaven 2026-06-10 22:24:23 +03:00
parent 6d48ed0341
commit a286d1e2c6
24 changed files with 3517 additions and 3755 deletions

View file

@ -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:<token>`. 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
├── 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 variables
└── 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/<slug>/` 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:<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.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`).

File diff suppressed because it is too large Load diff

View file

@ -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<string, Promise<string | null>>();
const MAX_CONCURRENT_DOWNLOADS = 4;
let active = 0;
const waiters: Array<() => void> = [];
const acquireSlot = async (): Promise<void> => {
if (active >= MAX_CONCURRENT_DOWNLOADS) {
await new Promise<void>((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<string | null> => {
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<string | null> => {
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<string | null>(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 | undefined>
): string | null => {
const [url, setUrl] = useState<string | null>(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;
};

View file

@ -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 `<commandPrefix> ` 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'),
},

View file

@ -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
// `* `<id>` (<RemoteName>) - `<state>``.
// 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+`<state>`` 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 <field.Name>\n<field.Description>`. 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
// (` * `<id>` `) 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<string, unknown> =>
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 ` - `<state>``, 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 ?? '<none>'}`
);
}
}
}
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);
}

View file

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

View file

@ -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 <field.Name>` 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: <go-error>`. 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: <go-error>` 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' };

View file

@ -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 (
<div class="contact-row">
<Avatar name={contact.name} colorKey={contact.id} src={avatarSrc} />
<div class="contact-main">
<div class="contact-name">
<span class="contact-name-text">
{contact.name || usernameText || phoneText || contact.id}
</span>
</div>
{usernameText || phoneText ? (
// Separate spans (not a ' · '-joined string) so narrow viewports
// wrap the phone onto its own line instead of ellipsizing both.
<div class="contact-handle">
{usernameText ? <span class="contact-handle-part">{usernameText}</span> : null}
{phoneText ? <span class="contact-handle-part">{phoneText}</span> : null}
</div>
) : null}
</div>
<button
type="button"
class={`contact-action${linked ? ' linked' : ''}`}
disabled={anotherBusy || busy}
onClick={onAction}
>
{busy ? (
<>
<Spinner />
{linked ? t('contacts.opening') : t('contacts.creating')}
</>
) : (
<>{linked ? t('contacts.open-chat') : t('contacts.start-chat')}</>
)}
</button>
</div>
);
};
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<ListState>({ kind: 'loading' });
const [query, setQuery] = useState('');
const [probe, setProbe] = useState<ProbeState | null>(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<string | null>(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<number | null>(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) => (
<ContactRow
key={busyKey}
contact={contact}
api={api}
t={t}
busy={busyId === busyKey}
anotherBusy={busyId !== null && busyId !== busyKey}
onAction={() => 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 (
<div class="probe-result">
<div class="probe-result-label missing">{t('contacts.probe-self')}</div>
</div>
);
}
return (
<div class="probe-result">
<div class="probe-result-label">{t('contacts.probe-found')}</div>
{renderRow(activeProbe.contact, `probe:${activeProbe.contact.id}`)}
</div>
);
}
if (activeProbe?.status === 'not-found') {
return (
<div class="probe-result">
<div class="probe-result-label missing">
{t('contacts.probe-not-found', { handle: activeProbe.identifier.display })}
</div>
</div>
);
}
const checking = activeProbe?.status === 'checking';
return (
<button type="button" class="probe-check" onClick={runProbe} disabled={checking}>
{checking ? <Spinner /> : <SearchIcon />}
{checking
? t('contacts.probe-checking', { handle: probeCandidate.display })
: t('contacts.probe-check', { handle: probeCandidate.display })}
</button>
);
};
return (
<div class="contacts">
<form
class="search-shell"
onSubmit={(e) => {
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();
}
}}
>
<span class="search-shell-icon" aria-hidden="true">
<SearchIcon />
</span>
<input
class="search-input"
type="text"
placeholder={t('contacts.search-placeholder')}
value={query}
onInput={(e) => setQuery((e.currentTarget as HTMLInputElement).value)}
/>
</form>
{!query && list.kind === 'ready' ? <div class="hint">{t('contacts.hint')}</div> : null}
{renderProbeArea()}
{list.kind === 'loading' ? (
<div class="contacts-placeholder" role="status">
<Spinner />
{t('contacts.loading')}
</div>
) : null}
{list.kind === 'error' ? (
<div class="contacts-placeholder">
<span class="contacts-error-text">{list.message || t('contacts.error')}</span>
<button type="button" class="recovery-action" onClick={load}>
<RefreshIcon />
{t('contacts.retry')}
</button>
</div>
) : null}
{list.kind === 'ready' ? (
<>
{filtered.length > 0 ? (
<div class="contact-list">{filtered.map((c) => renderRow(c, c.id))}</div>
) : (
<div class="contacts-placeholder">
{query.trim() ? t('contacts.empty-filtered') : t('contacts.empty')}
</div>
)}
</>
) : null}
</div>
);
};

View file

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

View file

@ -4,96 +4,139 @@
import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = {
'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 Vojos 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.',
};

View file

@ -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}.',

View file

@ -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
* cancelreopen 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<LoginUi>({ kind: 'idle' });
const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(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<AbortController | null>(null);
const lastPhoneRef = useRef('');
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
useEffect(
() => () => {
generation.current += 1;
waitAbort.current?.abort();
},
[]
);
return useMemo<LoginFlow>(() => {
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<string | undefined>(() =>
flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined
);
const inputRef = useRef<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.phone.title')}</div>
<label class="auth-card-hint" for="auth-phone-input">
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
{flagEmoji ? (
<span class="auth-phone-flag" aria-hidden="true">
{flagEmoji}
</span>
) : null}
<input
id="auth-phone-input"
ref={inputRef}
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
const next = formatPhoneInput(raw);
setValue(next.formatted);
setCountry(next.country);
}}
disabled={busy}
/>
</div>
<button type="submit" class="btn-primary" disabled={busy || inCooldown || !hasEnoughDigits}>
{submitLabel}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{showInvalidHint && !error ? (
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
) : null}
{error ? <div class="auth-card-error">{error}</div> : null}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.code.title')}</div>
<label class="auth-card-hint" for="auth-code-input">
{t('auth-card.code.label')}
</label>
<div class="auth-card-row">
<input
id="auth-code-input"
ref={inputRef}
class="auth-input code"
type="text"
autocomplete="one-time-code"
inputmode="numeric"
maxLength={6}
placeholder={t('auth-card.code.placeholder')}
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={busy}
/>
<button type="submit" class="btn-primary" disabled={busy || value.trim() === ''}>
{t('auth-card.code.submit')}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
{error ? <div class="auth-card-error">{error}</div> : 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 ? (
<div class="auth-card-countdown">
{t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.code.countdown-done')}</div>
))}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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<HTMLInputElement | null>(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 (
<form class={`auth-card${error ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.password.title')}</div>
<div class="auth-card-hint">{t('auth-card.password.hint')}</div>
<label class="auth-card-hint" for="auth-password-input">
{t('auth-card.password.label')}
</label>
<div class="auth-card-row">
<div class="auth-password-shell">
<input
id="auth-password-input"
ref={inputRef}
class="auth-input password"
type={reveal ? 'text' : 'password'}
autocomplete="current-password"
// Kill the HTML default size=20 intrinsic width so flex can
// shrink the input on narrow viewports (see styles.css notes).
size={1}
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={busy}
/>
<button
type="button"
class="auth-password-eye"
onClick={() => setReveal((v) => !v)}
aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
aria-pressed={reveal}
aria-controls="auth-password-input"
disabled={busy}
>
{reveal ? <EyeIcon /> : <EyeBlindIcon />}
</button>
</div>
<button type="submit" class="btn-primary" disabled={busy || value === ''}>
{t('auth-card.password.submit')}
</button>
<button type="button" class="btn-text" onClick={flow.cancel}>
{t('auth-card.cancel')}
</button>
</div>
{error ? <div class="auth-card-error">{error}</div> : null}
{busy && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --- 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 <rect>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(
<rect
key={`${r}-${c}`}
x={(c + margin) * cellPx}
y={(r + margin) * cellPx}
width={cellPx + 0.5 /* +0.5 px overlap to kill subpixel gaps on Android */}
height={cellPx + 0.5}
fill="#000"
/>
);
}
}
return (
<svg
width={pixelSize}
height={pixelSize}
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
role="img"
aria-label={ariaLabel}
>
{rects}
</svg>
);
};
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 (
<div class="auth-card auth-card-qr">
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
<div class="auth-card-qr-frame">
{matrix ? (
// The aria-label describes the PURPOSE of the QR, not its contents —
// the URL itself is the login secret.
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
) : (
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
<span class="dot" />
{t('auth-card.qr.preparing')}
</div>
)}
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.qr.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
)}
<ol class="auth-card-qr-steps">
<li>{t('auth-card.qr.step-1')}</li>
<li>{t('auth-card.qr.step-2')}</li>
<li>{t('auth-card.qr.step-3')}</li>
</ol>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};

View file

@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -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:<token>` — 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:<username>`, `tel:+<phone>`. */
identifiers?: string[];
/** Ghost MXID (`@telegram_<id>: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<string> | null = null;
public constructor(
private readonly baseUrl: string,
private readonly userId: string,
private readonly fetchCredentials: () => Promise<OpenIdCredentials>
) {}
// -- auth plumbing --
private getToken(force = false): Promise<string> {
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<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
const attempt = async (forceToken: boolean): Promise<T> => {
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<Whoami> {
return this.request<Whoami>('GET', '/v3/whoami');
}
public async listContacts(): Promise<Contact[]> {
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<Contact | null> {
try {
return await this.request<Contact>(
'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<Contact> {
return this.request<Contact>('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<LoginStep> {
return this.request<LoginStep>('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<string, string>
): Promise<LoginStep> {
return this.request<LoginStep>(
'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<LoginStep> {
return this.request<LoginStep>(
'POST',
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
stepId
)}/display_and_wait`,
{ signal, timeoutMs: null }
);
}
public loginCancel(loginId: string): Promise<void> {
return this.request<void>('POST', `/v3/login/cancel/${encodeURIComponent(loginId)}`);
}
public logout(loginId: string): Promise<void> {
return this.request<void>('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
* `+<digits>` 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 };
};

File diff suppressed because it is too large Load diff

View file

@ -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;
}
/* 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;
}
/* ── Linkified transcript bodies ─────────────────────────────────── */
.transcript-line a {
color: var(--fleet-soft);
text-decoration: underline;
/* 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;
}
.transcript-line a:hover {
color: var(--text);
}
/* ── 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 {

View file

@ -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 = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
export const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
export const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
export const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
export const SearchIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="9" cy="9" r="5.5" />
<line x1="13.2" y1="13.2" x2="17" y2="17" stroke-linecap="round" />
</svg>
);
export const BackIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12.5 4 L6.5 10 L12.5 16" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
export const UserIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="6.8" r="3.3" />
<path d="M3.8 17c.9-3 3.3-4.6 6.2-4.6s5.3 1.6 6.2 4.6" stroke-linecap="round" />
</svg>
);
export const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// 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 = () => (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1 12C1 12 5.92487 19 12 19C18.0751 19 23 12 23 12C23 12 18.0751 5 12 5C5.92487 5 1 12 1 12ZM2.90443 12C2.93793 12.0401 2.97258 12.0813 3.00836 12.1235C3.53083 12.7395 4.28523 13.5585 5.21221 14.3734C7.11461 16.0459 9.51515 17.5 12 17.5C14.4849 17.5 16.8854 16.0459 18.7878 14.3734C19.7148 13.5585 20.4692 12.7395 20.9916 12.1235C21.0274 12.0813 21.0621 12.0401 21.0956 12C21.0621 11.9599 21.0274 11.9187 20.9916 11.8765C20.4692 11.2605 19.7148 10.4415 18.7878 9.62656C16.8854 7.9541 14.4849 6.5 12 6.5C9.51515 6.5 7.11461 7.9541 5.21221 9.62656C4.28523 10.4415 3.53083 11.2605 3.00836 11.8765C2.97258 11.9187 2.93793 11.9599 2.90443 12Z"
/>
</svg>
);
export const EyeBlindIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.75213 3.69141L3.69147 4.75207L6.02989 7.09049C3.00297 9.15318 1 12.0001 1 12.0001C1 12.0001 5.92487 19.0001 12 19.0001C13.663 19.0001 15.2399 18.4756 16.6531 17.7137L19.2478 20.3084L20.3085 19.2478L4.75213 3.69141ZM15.5394 16.6L13.5242 14.5848C13.0775 14.8488 12.5565 15.0003 12 15.0003C10.3431 15.0003 9 13.6572 9 12.0003C9 11.4439 9.1515 10.9228 9.4155 10.4761L7.11135 8.17195C6.4387 8.61141 5.80156 9.10856 5.21221 9.62667C4.28523 10.4416 3.53083 11.2607 3.00836 11.8766C2.97258 11.9188 2.93793 11.96 2.90443 12.0001C2.93793 12.0402 2.97258 12.0814 3.00836 12.1236C3.53083 12.7396 4.28523 13.5586 5.21221 14.3736C7.11461 16.046 9.51515 17.5001 12 17.5001C13.2162 17.5001 14.4122 17.1518 15.5394 16.6ZM18.5058 14.6167C18.6009 14.5363 18.6949 14.4552 18.7878 14.3736C19.7148 13.5586 20.4692 12.7396 20.9916 12.1236C21.0274 12.0814 21.0621 12.0402 21.0956 12.0001C21.0621 11.96 21.0274 11.9188 20.9916 11.8766C20.4692 11.2607 19.7148 10.4416 18.7878 9.62667C16.8854 7.95422 14.4849 6.50011 12 6.50011C11.5118 6.50011 11.0268 6.55625 10.5482 6.65915L9.32458 5.43554C10.181 5.16161 11.0772 5.00011 12 5.00011C18.0751 5.00011 23 12.0001 23 12.0001C23 12.0001 21.6825 13.8727 19.5699 15.6808L18.5058 14.6167Z"
/>
</svg>
);
// --- 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) => (
<span
class="avatar"
aria-hidden="true"
style={{
width: `${size}px`,
height: `${size}px`,
fontSize: `${Math.round(size * 0.42)}px`,
background: avatarColor(colorKey),
}}
>
{initialsOf(name)}
{src ? <img class="avatar-img" src={src} alt="" loading="lazy" /> : null}
</span>
);
// --- 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) => (
<button
class={`command-card${danger ? ' danger' : ''}${spinning ? ' refreshing' : ''}`}
type="button"
onClick={onClick}
disabled={disabled}
>
<span class="command-card-lead-icon" aria-hidden="true">
{icon}
</span>
<div class="command-card-body">
<div class="command-card-name">{name}</div>
<div class="command-card-desc">{desc}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// --- Status / notice --------------------------------------------------------
type StatusPillProps = {
tone: 'connected' | 'disconnected' | 'checking';
children: ComponentChildren;
};
export const StatusPill = ({ tone, children }: StatusPillProps) => (
<span class={`section-status ${tone}`} role="status">
<span class="dot" />
{children}
</span>
);
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) => (
<div class={`notice ${tone}`} role={tone === 'error' ? 'alert' : 'status'}>
<span class="notice-body">{children}</span>
{onDismiss ? (
<button type="button" class="notice-dismiss" onClick={onDismiss} aria-label="×">
×
</button>
) : null}
</div>
);
export const Spinner = () => <span class="spinner" aria-hidden="true" />;

View file

@ -1,33 +1,16 @@
// Minimal matrix-widget-api transport implemented inline. We don't pull
// the full SDK because:
// - it's CommonJS and forces ESM interop juggling we hit on the dev
// fixture in Phase 2 (esm.sh wrapping made WidgetApi unavailable as
// a constructor);
// - the surface we use is small: capabilities reply, theme_change reply,
// send_event request, read_events request, get_openid request, live
// event delivery via send_event toWidget.
// the full SDK because the surface we use is tiny: the capability
// handshake (we request NO capabilities — the bridge is driven over its
// provisioning HTTP API, not over room events), theme_change pushes,
// MSC1960 OpenID credentials, and two `io.vojo.bot-widget` side-channel
// verbs (open-external-url / open-matrix-to).
//
// Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// (in the host repo). Default request timeout on the host transport is
// 10 s — keep that in mind for bridge-bot replies that take time.
// in the host repo.
import type { WidgetBootstrap } from './bootstrap';
export type RoomEvent = {
type: string;
event_id: string;
room_id: string;
sender: string;
origin_server_ts: number;
content: { msgtype?: string; body?: string; [k: string]: unknown };
unsigned: Record<string, unknown>;
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
// for forward-compat; the widget-side parser reads either.
redacts?: string;
};
type ToWidgetMessage = {
api: 'toWidget';
widgetId: string;
@ -35,8 +18,6 @@ type ToWidgetMessage = {
action: string;
data: Record<string, unknown>;
// Present when this message IS a reply to a prior toWidget request.
// Per matrix-widget-api PostmessageTransport: replies preserve the original
// `api` field and add `response`. Both directions follow the same shape.
response?: Record<string, unknown>;
};
@ -49,16 +30,36 @@ type FromWidgetMessage = {
response?: Record<string, unknown>;
};
export type Capability = string;
export type OpenIdCredentials = {
accessToken: string;
/** Seconds until the token expires (Synapse default 3600). */
expiresIn: number;
matrixServerName: string;
};
export type WidgetApiEvents = {
ready: () => void;
liveEvent: (ev: RoomEvent) => void;
themeChange: (name: 'light' | 'dark') => void;
};
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
// MSC1960 has a two-phase path: the host may reply `state: "request"`
// (user confirmation pending) and deliver the credentials later via a
// separate `openid_credentials` toWidget action. Vojo's host auto-grants
// for allowlisted first-party widgets, so phase 2 normally never happens —
// the generous timeout only matters if a future host adds a consent UI.
const OPENID_PHASE2_TIMEOUT_MS = 120_000;
const parseOpenIdCredentials = (raw: Record<string, unknown>): OpenIdCredentials | null => {
const accessToken = raw.access_token;
const matrixServerName = raw.matrix_server_name;
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
if (typeof matrixServerName !== 'string' || matrixServerName.length === 0) return null;
const expiresIn = typeof raw.expires_in === 'number' ? raw.expires_in : 3600;
return { accessToken, expiresIn, matrixServerName };
};
export class WidgetApi {
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
@ -67,14 +68,19 @@ export class WidgetApi {
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
>();
// Phase-2 OpenID waiters keyed by the ORIGINAL get_openid requestId —
// the host's `openid_credentials` action carries it as
// `original_request_id`.
private readonly pendingOpenId = new Map<
string,
{ resolve: (v: OpenIdCredentials) => void; reject: (e: Error) => void; timer: number }
>();
private requestSeq = 0;
private isReady = false;
public constructor(
private readonly bootstrap: WidgetBootstrap,
private readonly capabilities: Capability[]
) {
public constructor(private readonly bootstrap: WidgetBootstrap) {
window.addEventListener('message', this.onMessage);
}
@ -82,6 +88,11 @@ export class WidgetApi {
window.removeEventListener('message', this.onMessage);
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
this.pending.clear();
this.pendingOpenId.forEach(({ reject, timer }) => {
window.clearTimeout(timer);
reject(new Error('disposed'));
});
this.pendingOpenId.clear();
}
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
@ -90,83 +101,82 @@ export class WidgetApi {
// `ready` is a one-shot lifecycle signal. If the handshake completed
// before this listener attached (cached-bundle race: host fires the
// capabilities request on iframe `load`, the WidgetApi catches and
// resolves it during script init, then React's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously so
// App.tsx still flips `handshakeOk` and fires `list-logins`.
// resolves it during script init, then Preact's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously.
if (event === 'ready' && this.isReady) {
(listener as () => void)();
}
}
public sendText(body: string): Promise<{ event_id: string }> {
return this.fromWidget('send_event', {
type: 'm.room.message',
content: { msgtype: 'm.text', body },
}) as Promise<{ event_id: string }>;
// MSC1960: ask the host for OpenID credentials proving our Matrix
// identity. The provisioning client exchanges them with the bridge
// (`Authorization: Bearer openid:<token>`); they are NOT a Matrix access
// token and grant no homeserver power. Rejects when the host driver
// blocks the request (BotWidgetDriver gates on the `vojo.openid`
// capability opt-in in config.json).
public async getOpenIdCredentials(): Promise<OpenIdCredentials> {
const requestId = this.nextRequestId();
const reply = await this.fromWidget('get_openid', {}, requestId);
const state = reply.state;
if (state === 'allowed') {
const creds = parseOpenIdCredentials(reply);
if (!creds) throw new Error('host returned malformed OpenID credentials');
return creds;
}
if (state === 'request') {
// Phase 2: credentials arrive via the `openid_credentials` action.
return new Promise<OpenIdCredentials>((resolve, reject) => {
const timer = window.setTimeout(() => {
this.pendingOpenId.delete(requestId);
reject(new Error('OpenID confirmation timed out'));
}, OPENID_PHASE2_TIMEOUT_MS);
this.pendingOpenId.set(requestId, { resolve, reject, timer });
});
}
throw new Error('OpenID request blocked by host');
}
// MSC4039 media download. The host driver only honours mxc URIs and
// substitutes a 96px crop thumbnail — this exists for avatars, not for a
// media viewer. Media on the homeserver is authenticated; the host fetches
// with its token and ships the bytes here as a Blob (structured clone).
public async downloadFile(mxcUri: string): Promise<Blob> {
const reply = await this.fromWidget('org.matrix.msc4039.download_file', {
content_uri: mxcUri,
});
const file = (reply as { file?: unknown }).file;
if (file instanceof Blob) return file;
if (file === undefined || file === null) throw new Error('host returned no file');
// Other XMLHttpRequestBodyInit shapes (ArrayBuffer, string) — normalize.
return new Blob([file as BlobPart]);
}
// Open an external URL via the host. The host receives this on a
// SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from
// matrix-widget-api's `fromWidget` so it doesn't route through
// ClientWidgetApi's request/response machinery.
//
// Why this exists: cross-origin iframes inside Capacitor's Android
// WebView silently drop `<a target="_blank">` clicks — the WebView
// doesn't have a multi-window concept, and the host's global
// `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks
// inside the host document, not inside the iframe (cross-origin
// events don't bubble across the frame boundary). The widget posts
// this message instead; the host calls `openExternalUrl(url)` which
// routes to `Browser.open` on native and `window.open` on web.
// ClientWidgetApi's request/response machinery. Needed because
// cross-origin iframes inside Capacitor's Android WebView silently drop
// `<a target="_blank">` 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<string, unknown>): 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 `<commandPrefix> ` (trailing space —
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
// the management room and any other room the bot may have been moved to.
// Form-field submissions (phone / 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<RoomEvent[]> {
const data: Record<string, unknown> = {
type: opts.type ?? 'm.room.message',
limit: opts.limit,
room_ids: [this.bootstrap.roomId],
};
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
return (res.events as RoomEvent[] | undefined) ?? [];
}
private emit<K extends keyof WidgetApiEvents>(
event: K,
...args: Parameters<WidgetApiEvents[K]>
@ -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,26 +265,19 @@ 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<RoomEvent> | 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;
}
@ -284,10 +290,10 @@ export class WidgetApi {
private fromWidget(
action: string,
data: Record<string, unknown>
data: Record<string, unknown>,
requestId = this.nextRequestId()
): Promise<Record<string, unknown>> {
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',
];

View file

@ -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"]
}
},
{

View file

@ -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<Capability> =>
// 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:<token>`),
// 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<IOpenIDUpdate>): void {
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

View file

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

View file

@ -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<void> =>
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<string | undefined> =>
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]
);

View file

@ -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<string> = 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<string> = new Set([BOT_CAP_ADD_TO_CHAT, BOT_CAP_OPENID]);
export type BotPreset = {
/** Stable URL slug — `/bots/<id>`. 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;
}

View file

@ -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,