feat(bots-telegram): ship M12 login flow with BotShell host hero and Go bridgev2 dialect parser

This commit is contained in:
v.lagerev 2026-05-02 22:12:37 +03:00
parent 55eaa7b502
commit 691eb8530a
30 changed files with 2972 additions and 351 deletions

File diff suppressed because it is too large Load diff

View file

@ -11,6 +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;
theme: 'light' | 'dark';
clientLanguage: string;
};
@ -19,7 +25,7 @@ export type BootstrapResult =
| { ok: true; bootstrap: WidgetBootstrap }
| { ok: false; missing: string[] };
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
export const readBootstrap = (search: string): BootstrapResult => {
const params = new URLSearchParams(search);
@ -51,6 +57,7 @@ export const readBootstrap = (search: string): BootstrapResult => {
userId: get('userId'),
botId: get('botId'),
botMxid: get('botMxid'),
commandPrefix: get('commandPrefix'),
theme,
clientLanguage: get('clientLanguage'),
},

View file

@ -0,0 +1,309 @@
// 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 } 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;
// --- 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() };
// 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' };
};
// --- 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' },
],
['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`
);
}
}
}
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

@ -0,0 +1,14 @@
// Parser shim. The widget consumes a single `parseReply(body)` from
// elsewhere; this file picks the active dialect. M12 ships exactly one —
// `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 } from './types';
import { parseGoV2604 } from './dialects/go_v2604';
export const parseReply = (body: string): LoginEvent => parseGoV2604(body);

View file

@ -0,0 +1,47 @@
// 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;
};
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 }
| { kind: 'unknown' };

View file

@ -4,17 +4,62 @@
import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = {
'hero.description':
'Manage the Telegram bridge. Commands are sent as text into the control DM; replies are visible in the transcript.',
'status.waiting': 'Connecting…',
'status.ok': 'Ready',
'section.check': 'Check',
'section.transcript': 'Transcript',
'card.ping.desc': 'Check Telegram authentication status via the bot.',
'hint.m11': 'M11: handshake and bot connectivity check only. Login commands arrive in M12.',
'transcript.empty': 'empty',
'status.unknown': 'Checking status…',
'status.disconnected': 'Sign in to Telegram',
'status.connected': 'Telegram linked',
'status.connected-as': 'Telegram linked as {handle}',
'status.logging-out': 'Signing out…',
'section.transcript': 'Logs',
'card.login.name': '/login',
'card.login.desc': 'By phone number',
'card.refresh.aria': 'Refresh status',
'card.refresh.label': 'Refresh status',
'landing.hint': 'The bot replies in this chat — forms appear below.',
'auth-card.phone.title': 'Phone login',
'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.submit': 'Send code',
'auth-card.phone.cooldown': 'Retry in {seconds}s',
'auth-card.code.title': 'Verification code',
'auth-card.code.label': 'SMS code',
'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.privacy-hint-history':
'The code you entered is still in the room history — clear it manually if you want.',
'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.',
'auth-card.password.label': 'Password',
'auth-card.password.submit': 'Confirm',
'auth-card.password.show': 'Show',
'auth-card.password.hide': 'Hide',
'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-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': '/logout',
'card.logout.desc': 'Sign out of Telegram',
'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}',
'bootstrap.failed': 'Widget failed to start',
'bootstrap.missing-params': 'Missing required URL params: {names}.',

View file

@ -4,20 +4,82 @@
// 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 = {
'hero.description':
'Управление мостом Telegram. Команды отправляются текстом в контрольный DM, ответы видны в транскрипте.',
'status.waiting': 'Подключение…',
'status.ok': 'Готов',
'section.check': 'Проверка',
'section.transcript': 'Транскрипт',
'card.ping.desc': 'Проверить статус авторизации в Telegram через бот.',
'hint.m11': "M11: только проверка handshake'а и связи с ботом. Команды логина появятся в M12.",
'transcript.empty': 'пусто',
// --- Inline section status ---------------------------------------------
'status.unknown': 'Проверка статуса…',
'status.disconnected': 'Войдите в Telegram',
'status.connected': 'Telegram привязан',
'status.connected-as': 'Telegram привязан как {handle}',
'status.logging-out': 'Завершение сеанса…',
// --- Section headers ---------------------------------------------------
'section.transcript': 'Логи',
'card.login.name': '/login',
// Card desc is descriptive (noun-style), not a third call-to-action — the
// section status «Войдите в Telegram» already carries the imperative.
'card.login.desc': 'По номеру телефона',
'card.refresh.aria': 'Обновить статус',
'card.refresh.label': 'Обновить статус',
'landing.hint': 'Бот ответит в этом чате — формы появятся ниже.',
// --- Phone form --------------------------------------------------------
'auth-card.phone.title': 'Вход по номеру',
'auth-card.phone.label': 'Номер телефона',
'auth-card.phone.placeholder': '+79991234567',
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
'auth-card.phone.submit': 'Отправить код',
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
// --- Code form ---------------------------------------------------------
'auth-card.code.title': 'Код подтверждения',
'auth-card.code.label': 'Код из SMS',
'auth-card.code.placeholder': '123456',
'auth-card.code.submit': 'Подтвердить',
'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.',
'auth-card.code.privacy-hint-history':
'Введённый код остался в истории комнаты — при желании очистите вручную.',
// --- 2FA password form -------------------------------------------------
'auth-card.password.title': 'Облачный пароль Telegram',
'auth-card.password.hint':
'У вашего аккаунта включена двухэтапная проверка. Введите облачный пароль Telegram — это не пароль от Vojo.',
'auth-card.password.label': 'Пароль',
'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': 'Не пришло — нажмите «Отмена» и попробуйте снова.',
// --- 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 ------------------------------------------------------------
'card.logout.name': '/logout',
'card.logout.desc': 'Выйти из Telegram',
'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}',
// --- Bootstrap failure -------------------------------------------------
'bootstrap.failed': 'Widget не запустился',
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',

View file

@ -2,6 +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 './styles.css';
const root = document.getElementById('app');
@ -32,5 +33,20 @@ if (!result.ok) {
// Apply initial theme synchronously so the first paint isn't flashed
// through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme;
render(<App bootstrap={result.bootstrap} />, root);
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
// the `window.addEventListener('message', ...)` listener synchronously,
// so by the time the host's ClientWidgetApi fires its capabilities
// request on iframe `load` we're already listening.
//
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
// runs AFTER React's first commit. On a fresh mount the bundle parse +
// initial render took long enough for the host's request to arrive
// after the listener was attached, so it worked by accident. On the
// *second* mount (after «Show chat» → «Show widget») the bundle is
// browser-cached and parses near-instantly; the host's request raced
// ahead of useEffect, the listener missed it, and capability handshake
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
render(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -0,0 +1,310 @@
// Login state machine — consumes LoginEvent (one per inbound m.notice from
// the bridge bot) and emits a typed UI state. The widget renders forms and
// the status pill from this state, never from raw reply strings.
//
// Multi-reply collapse is implemented here: when the bot emits two notices
// for a single transition (2fa instructions + password re-prompt; invalid
// code + code re-prompt; wrong password + password re-prompt), the second
// notice arrives as `awaiting_password` / `awaiting_code` and the reducer
// recognises it as a no-op against the state already set by the first.
//
// State-gating policy: prompt events (`awaiting_*`) and step-error events
// (`twofa_required`, `invalid_code`, `wrong_password`, `submit_failed`,
// `invalid_value`) are valid only from a *plausible previous state*.
// Without these gates, late prompt-events can resurrect cancelled or
// completed flows — e.g. user submits phone, clicks Cancel, bot's pipeline
// already started a Telegram API call and emits `Please enter your Code…`
// AFTER the cancel reply lands. The reducer here ignores that late prompt
// because we're already `disconnected`.
import type { LoginEvent, ListedLogin } from './bridge-protocol/types';
export type LoginErrorFlag =
| { kind: 'invalid_code' }
| { kind: 'wrong_password' }
| { kind: 'submit_failed'; reason?: string }
| { kind: 'invalid_value'; reason?: string }
| { kind: 'prepare_failed'; reason?: string }
| { kind: 'start_failed'; reason?: string }
| { kind: 'login_in_progress' }
| { kind: 'max_logins'; limit?: number }
| { kind: 'unknown_command' };
export type LoginState =
// Pre-handshake / pre-list-logins. Status pill: --faint.
| { kind: 'unknown' }
// list-logins came back empty, OR logout completed. Status pill: --rose
// (disconnected = needs action).
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
// After "Войти по номеру" — waiting for `Please enter your Phone number`.
// Status pill: --amber (in flight).
| { kind: 'awaiting_phone'; lastError?: LoginErrorFlag }
// After phone submit — waiting for code prompt OR error reply.
// Status pill: --amber.
| { kind: 'awaiting_code'; lastError?: LoginErrorFlag }
// After code submit (when the bot decided 2fa is needed) — waiting for
// password submission. lastError carries `wrong_password` after a failed
// password retry. Status pill: --amber.
| { kind: 'awaiting_password'; lastError?: LoginErrorFlag }
// logout in flight — waiting for `Logged out`. Status pill: --amber.
| { kind: 'logging_out'; loginId: string }
// Live session. login carries the parsed handle/numericId from
// `Successfully logged in as <handle> (<id>)`, plus the loginId we need
// for `!tg logout <id>`. Status pill: --green.
| {
kind: 'connected';
handle: string;
numericId?: string;
loginId?: string;
};
// Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions (start_login, request_logout,
// cancel_pending) optimistically advance state — the App rolls them back
// on send-failure where the bot would otherwise leave us stuck.
export type LoginAction =
| { kind: 'event'; event: LoginEvent }
| { kind: 'start_login' } // user clicked "Войти по номеру"
| { kind: 'submit_phone' } // user clicked submit on phone form
| { kind: 'submit_code' } // user clicked submit on code form
| { kind: 'submit_password' } // user clicked submit on 2fa form
| { kind: 'request_logout'; loginId: string } // user clicked "Выйти"
| { kind: 'cancel_pending' }; // user clicked "Отмена"
export const initialLoginState: LoginState = { kind: 'unknown' };
const pickConnected = (logins: ListedLogin[]): LoginState => {
if (logins.length === 0) return { kind: 'disconnected' };
// M12 ships single-account UI (max_logins=1 in the operator's bridge
// config). If a future deployment runs with multiple logins, we still
// surface the first one — multi-account UI is a follow-up phase. The
// loginId here is what the widget will pass to `!tg logout <id>`.
const [first] = logins;
return {
kind: 'connected',
handle: first.name,
loginId: first.id,
};
};
// Whether a `awaiting_code` prompt is plausible from the current state.
// Plausible: just submitted phone (still in awaiting_phone), or the bot
// is re-prompting after invalid_code (we're already in awaiting_code).
const acceptsCodePrompt = (s: LoginState): boolean =>
s.kind === 'awaiting_phone' || s.kind === 'awaiting_code';
// Whether a `awaiting_password` re-prompt is plausible. The TRANSITION to
// password (from awaiting_code) is driven by `twofa_required`, not by the
// re-prompt itself; the re-prompt only confirms we're still waiting.
const acceptsPasswordReprompt = (s: LoginState): boolean => s.kind === 'awaiting_password';
// Whether `twofa_required` is plausible. It can only follow a code submit.
const acceptsTwofa = (s: LoginState): boolean => s.kind === 'awaiting_code';
// Whether step-scoped errors (invalid_code, wrong_password, invalid_value,
// submit_failed) should land on a form. Form-scoped errors are dropped
// when no form is open.
const isFormState = (
s: LoginState
): s is
| { kind: 'awaiting_phone'; lastError?: LoginErrorFlag }
| { kind: 'awaiting_code'; lastError?: LoginErrorFlag }
| { kind: 'awaiting_password'; lastError?: LoginErrorFlag } =>
s.kind === 'awaiting_phone' || s.kind === 'awaiting_code' || s.kind === 'awaiting_password';
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'start_login') {
return { kind: 'awaiting_phone' };
}
if (action.kind === 'submit_phone') {
// Stay on the phone form until the bot confirms with `awaiting_code`.
// Optimistic transition to awaiting_code would mis-surface a phone-side
// error (e.g. `submit_failed: PHONE_NUMBER_BANNED`) on the code form.
if (state.kind === 'awaiting_phone') {
return { kind: 'awaiting_phone', lastError: undefined };
}
return state;
}
if (action.kind === 'submit_code') {
if (state.kind === 'awaiting_code') {
return { kind: 'awaiting_code', lastError: undefined };
}
return state;
}
if (action.kind === 'submit_password') {
if (state.kind === 'awaiting_password') {
return { kind: 'awaiting_password', lastError: undefined };
}
return state;
}
if (action.kind === 'request_logout') {
return { kind: 'logging_out', loginId: action.loginId };
}
if (action.kind === 'cancel_pending') {
// Optimistic: drop straight back to disconnected. The bot's reply will
// be `Login cancelled.` (cancel_ok) or `No ongoing command.`
// (cancel_no_op) — either way the user has signalled they want out.
return { kind: 'disconnected' };
}
const event = action.event;
switch (event.kind) {
case 'logins_listed':
// list-logins is the source of truth — accept from any state.
return pickConnected(event.logins);
case 'not_logged_in':
// Same gating idea as the prompt events: a late-arriving
// `You're not logged in` from a list-logins fired before the user
// started a fresh login flow would otherwise wipe an active form.
// Accept only from states where flipping to disconnected is correct.
if (
state.kind === 'unknown' ||
state.kind === 'disconnected' ||
state.kind === 'logging_out'
) {
return { kind: 'disconnected' };
}
return state;
case 'awaiting_phone':
// Bot's "Please enter your Phone number". Only meaningful when we
// initiated phone-login (state already awaiting_phone). From any
// other state — including the late-arriving prompt after a cancel
// — drop it on the floor.
return state.kind === 'awaiting_phone' ? state : state;
case 'awaiting_code':
// Plausible after submitting phone, or as a re-prompt within the
// code form. Late arrival after cancel/connected/logging_out is
// ignored to avoid resurrecting dead flows.
if (!acceptsCodePrompt(state)) return state;
if (state.kind === 'awaiting_phone') return { kind: 'awaiting_code' };
return state;
case 'awaiting_password':
// Pure re-prompt arm. The TRANSITION to awaiting_password is driven
// by `twofa_required` (or `wrong_password`), not by this event.
// Ignored when we're not already on the password form.
if (!acceptsPasswordReprompt(state)) return state;
return state;
case 'twofa_required':
// First of the two-reply 2fa transition. Only valid after a code
// submit. Ignored from disconnected/connected/etc.
if (!acceptsTwofa(state)) return state;
return { kind: 'awaiting_password' };
case 'invalid_code':
if (state.kind !== 'awaiting_code') return state;
return { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } };
case 'wrong_password':
if (state.kind !== 'awaiting_password') return state;
return { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } };
case 'login_success':
// Always honour — even if state somehow drifted, the bridge says we're in.
return {
kind: 'connected',
handle: event.handle,
numericId: event.numericId,
// loginId is unknown until the post-success list-logins fires
// (App.tsx). Until then, logout is gated.
};
case 'logout_ok':
// Late `Logged out` from a previous session can arrive while the user
// is mid-new-flow (e.g. they cancelled, started login again, and the
// old logout's reply finally lands). Only honour from logging_out;
// other states keep their flow.
if (state.kind !== 'logging_out') return state;
return { kind: 'disconnected' };
case 'cancel_ok':
case 'cancel_no_op':
// The App's `cancel_pending` action ALWAYS optimistically lands us
// in `disconnected` before the bot's confirmation arrives. So a
// legitimate cancel-reply naturally finds state === 'disconnected'
// — accepting it then is a safe idempotent no-op transition.
//
// From ANY other state (awaiting_*, connected, logging_out,
// unknown), the cancel reply is stale: the user has either started
// a new flow (state already moved on) or never cancelled in this
// widget session at all. Letting it through would clobber an
// active flow — exactly the race the reviewer flagged: cancel +
// immediate re-login = late cancel_ok kicking awaiting_phone
// back to disconnected.
//
// (Out-of-band manual `!tg cancel` typed in chat-fallback while
// the widget shows an active form would also be ignored. That's
// accepted scope: we don't run a causality/epoch system, and the
// chat-fallback flow is an escape hatch, not a primary surface.)
if (state.kind !== 'disconnected') return state;
return { kind: 'disconnected' };
case 'login_in_progress':
// Surfaces when the user clicked Войти по номеру but the bridge
// already has a stale flow open. Form-level warning if a form is
// open; otherwise dropped so we don't manufacture a disconnected
// banner from nothing.
if (isFormState(state)) {
return { ...state, lastError: { kind: 'login_in_progress' } };
}
return state;
case 'max_logins':
// Should not fire for max_logins=1 operators when our UI hides
// login while connected. If it does fire, the user is in a race;
// surface the error on disconnected so they can logout first.
return { kind: 'disconnected', lastError: { kind: 'max_logins', limit: event.limit } };
case 'login_not_found':
// Logout target id was wrong. Treat as disconnected — bridge clearly
// doesn't know that login id any more.
return { kind: 'disconnected' };
case 'invalid_value':
// Bridge rejected our submitted phone/code/password (e.g. malformed
// phone). Keep the form open with an error; if no form is open,
// ignore so we don't pollute disconnected state.
if (!isFormState(state)) return state;
return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } };
case 'submit_failed':
// Telegram-side error (FloodWait, banned, etc.) leaked through
// bridgev2's commands layer. Hold the current form open so the user
// can retry; surface the verbatim Go error tail in the warning.
if (!isFormState(state)) return state;
return { ...state, lastError: { kind: 'submit_failed', reason: event.reason } };
case 'prepare_failed':
return { kind: 'disconnected', lastError: { kind: 'prepare_failed', reason: event.reason } };
case 'start_failed':
return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } };
case 'flow_required':
case 'flow_invalid':
// We always send `login phone` so this shouldn't happen. If it does,
// the operator-config / bridge mismatch is loud enough to fail
// visibly on the disconnected screen.
return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: 'flow' } };
case 'unknown_command':
// Shouldn't happen — we only send commands the bridge knows. If it
// does, the operator-config / bridge image is mismatched; surface it
// loudly on the disconnected screen so the misconfig is visible.
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
case 'unknown':
return state;
default: {
// Exhaustiveness check — if a new LoginEvent kind is added without a
// case, TypeScript will flag this as a compile error.
const exhaustive: never = event;
return exhaustive;
}
}
};

View file

@ -71,124 +71,12 @@ body {
margin: 0 auto;
}
/* ── Hero ─────────────────────────────────────────────────────────── */
.hero {
display: flex;
align-items: flex-start;
gap: 18px;
padding: 36px var(--section-pad-x) 28px;
border-bottom: 1px solid var(--divider);
}
.hero-avatar {
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--fleet);
color: #0c0c0e;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-body {
flex: 1;
min-width: 0;
}
.hero-title-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.hero-name {
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.hero-handle {
font-size: 13px;
color: var(--faint);
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
word-break: break-all;
}
.hero-description {
font-size: 14px;
line-height: 20px;
color: var(--muted);
max-width: 560px;
}
.hero-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--divider);
font-size: 13px;
color: var(--muted);
flex-shrink: 0;
white-space: nowrap;
}
.hero-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
.hero-status.ok {
color: var(--green);
}
.hero-status.ok .dot {
background: var(--green);
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
}
.hero-status.waiting {
color: var(--amber);
}
.hero-status.waiting .dot {
background: var(--amber);
}
.hero-status.error {
color: var(--rose);
}
.hero-status.error .dot {
background: var(--rose);
}
@media (max-width: 600px) {
.hero {
flex-wrap: wrap;
gap: 14px;
padding-top: 24px;
padding-bottom: 18px;
}
.hero-status {
order: 3;
margin-left: 0;
}
.hero-name {
font-size: 19px;
}
.hero-avatar {
width: 48px;
height: 48px;
font-size: 20px;
}
}
/* The hero (avatar + name + handle + description + Настроить dropdown) is
* OWNED BY THE HOST, not the widget see src/app/features/bots/BotShell.tsx.
* Removing the widget-side hero collapses the duplicate header that used to
* sit between the host's BotShellHero (which the user actually sees) and
* the iframe content. The widget body now starts with the active-state
* section directly. */
/* ── Section ──────────────────────────────────────────────────────── */
@ -200,23 +88,128 @@ 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 {
font-size: 12px;
color: var(--muted);
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 .refresh-button and the host
* hero settings button. */
.section-status {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
line-height: 20px;
color: var(--muted);
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
margin: 0 0 14px;
user-select: none;
white-space: nowrap;
}
.section-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
.section-status.connected {
color: var(--green);
}
.section-status.connected .dot {
background: var(--green);
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
}
.section-status.disconnected {
color: var(--rose);
}
.section-status.disconnected .dot {
background: var(--rose);
}
.section-status.checking {
color: var(--amber);
}
.section-status.checking .dot {
background: var(--amber);
}
/* Wraps the section-status pill + a labeled refresh action when the
* state has no other affordance (unknown / logging_out / connected
* without loginId). Without this row, the user can stare at a
* «Проверка статуса» pill forever if the first list-logins reply
* dropped on the wire. */
.section-recovery-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
/* ── Command card (single + 2-col grid both fit) ─────────────────── */
.command-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
.section-recovery-row > .section-status {
margin-bottom: 0;
}
.recovery-action {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
line-height: 20px;
color: var(--muted);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.recovery-action:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: var(--hairline);
}
.recovery-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recovery-action svg {
width: 16px;
height: 16px;
}
/* ── Command card (action card with name + desc + chevron) ──────── */
.command-card {
background: var(--bg2);
border: 1px solid var(--divider);
@ -248,7 +241,7 @@ body {
}
.command-card-name {
font-size: 14px;
font-size: 15px;
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
color: var(--fleet-soft);
font-weight: 500;
@ -256,9 +249,9 @@ body {
}
.command-card-desc {
font-size: 13px;
font-size: 14px;
color: var(--muted);
line-height: 18px;
line-height: 19px;
}
.command-card-chevron {
@ -280,6 +273,28 @@ body {
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;
}
.transcript::-webkit-scrollbar {
width: 8px;
}
.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 {
@ -329,6 +344,337 @@ body {
font-style: italic;
}
.command-card.danger .command-card-name {
color: var(--rose);
}
.command-card.danger:hover:not(:disabled) {
border-color: var(--rose);
}
/* Inline confirm-in-place body for the destructive logout card. The button
* group lives inside the same card frame no modal, no layout shift. */
.command-card-confirm {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
}
.command-card-confirm-prompt {
font-size: 14px;
color: var(--text);
flex: 1;
min-width: 0;
}
.command-card-confirm-yes,
.command-card-confirm-no,
.refresh-button,
.btn-primary,
.btn-text,
.btn-icon {
font: inherit;
cursor: pointer;
}
.command-card-confirm-yes {
background: var(--rose);
color: #0c0c0e;
border: none;
border-radius: 7px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
}
.command-card-confirm-no {
background: transparent;
color: var(--muted);
border: 1px solid var(--divider);
border-radius: 7px;
padding: 7px 14px;
font-size: 13px;
}
.command-card-confirm-yes:disabled,
.command-card-confirm-no:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Refresh button ──────────────────────────────────────────────── */
.refresh-button {
/* Square side = .command-card's content height: padding 14*2 = 28,
* name lh ~18, name margin-bottom 3, desc lh 19, border 2 = 70px.
* Hard-coded because flex-stretch + aspect-ratio:1 doesn't reliably
* propagate across browsers when neither axis is explicitly sized
* (the icon's intrinsic 18×18 wins in the cross-axis-from-aspect
* resolution). 70px keeps the chip flush with the login card's
* top and bottom edges. */
width: 70px;
height: 70px;
border-radius: 10px;
background: var(--bg2);
border: 1px solid var(--divider);
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.refresh-button:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
color: var(--text);
}
.refresh-button:active:not(:disabled) {
background: var(--surface2);
}
.refresh-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-button svg {
width: 26px;
height: 26px;
display: block;
}
/* Connect-row holds the `.command-grid` (flex-grows; the grid auto-fill
* caps the login card at one column-width so it doesn't stretch across
* the full row) and the square refresh button beside it. flex-wrap is
* defensive for sub-360px viewports. */
.connect-row {
display: flex;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
}
.connect-row > .command-grid {
flex: 1;
min-width: 0;
}
.command-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
/* ── Auth card (login forms inside transcript section) ───────────── */
.auth-card {
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-card.error {
border-color: var(--rose);
}
.auth-card-title {
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
}
.auth-card-hint {
font-size: 14px;
color: var(--muted);
line-height: 19px;
}
.auth-card-row {
display: flex;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
}
.auth-input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 8px;
padding: 11px 14px;
color: var(--text);
font: inherit;
font-size: 15px;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.auth-input:hover:not(:focus):not(:disabled) {
border-color: rgba(255, 255, 255, 0.16);
}
.auth-input:focus {
border-color: var(--fleet);
/* Stronger ring than border-color alone matches Dawn's emphasis on
* accent halos (BotsDesktop avatar shadow / hero-status.ok glow). */
box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18);
}
.auth-card.error .auth-input {
border-color: var(--rose);
}
.auth-card.error .auth-input:focus {
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
}
.auth-input.code,
.auth-input.password {
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
letter-spacing: 4px;
font-size: 20px;
}
.password-row {
display: flex;
align-items: stretch;
gap: 6px;
flex: 1;
min-width: 0;
}
.btn-icon {
background: transparent;
border: 1px solid var(--divider);
border-radius: 8px;
color: var(--muted);
padding: 0 12px;
font-size: 13px;
flex-shrink: 0;
}
.btn-icon:hover {
color: var(--text);
border-color: var(--hairline);
}
.btn-primary {
background: var(--fleet);
color: #0c0c0e;
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-text {
background: transparent;
border: none;
color: var(--muted);
padding: 10px 12px;
font-size: 13px;
}
.btn-text:hover:not(:disabled) {
color: var(--text);
}
.auth-card-error {
font-size: 13px;
line-height: 18px;
color: var(--rose);
}
.auth-card-warn {
font-size: 13px;
line-height: 18px;
color: var(--amber);
}
.auth-card-waiting {
font-size: 13px;
color: var(--faint);
line-height: 18px;
}
/* Countdown text on the code form: same baseline tone as waiting hint
* but a touch more prominent because it carries an actual number. The
* color tween softens the mutedamber transition at expiry without it
* the line jumps between palettes mid-sentence, which reads broken
* against Dawn's measured aesthetic. */
.auth-card-countdown {
font-size: 13px;
color: var(--muted);
line-height: 18px;
font-variant-numeric: tabular-nums;
transition: color 0.2s ease-out;
}
.auth-card-countdown.expired {
color: var(--amber);
}
@media (max-width: 600px) {
.auth-card-row {
flex-direction: column;
}
.btn-primary,
.btn-text {
width: 100%;
}
/* Compact .command-card on mobile so its height matches the shrunk
* refresh button below; preserves the «two-row title + chevron»
* structure. */
.command-card {
padding: 12px 14px;
border-radius: 8px;
}
.command-card-name {
font-size: 14px;
margin-bottom: 2px;
}
.command-card-desc {
font-size: 13px;
line-height: 17px;
}
/* Allow the grid to shrink below its 280px desktop floor so it doesn't
* push the refresh button onto its own wrap line at sub-360px. */
.command-grid {
grid-template-columns: minmax(0, 1fr);
}
/* Refresh side compact card height (padding 12*2 + name 17 + margin
* 2 + desc 17 + border 2 = 62px). Keeps the chip flush with the
* card's top and bottom edges on mobile. */
.refresh-button {
width: 62px;
height: 62px;
border-radius: 8px;
}
.refresh-button svg {
width: 22px;
height: 22px;
}
}
/* ── Linkified transcript bodies ─────────────────────────────────── */
.transcript-line a {
color: var(--fleet-soft);
text-decoration: underline;
}
.transcript-line a:hover {
color: var(--text);
}
/* ── Hint text ───────────────────────────────────────────────────── */
.hint {

View file

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

View file

@ -83,6 +83,15 @@ export class WidgetApi {
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
list.push(listener);
// `ready` is a one-shot lifecycle signal. If the handshake completed
// before this listener attached (cached-bundle race: host fires the
// capabilities request on iframe `load`, the WidgetApi catches and
// resolves it during script init, then React's useEffect runs *after*
// that and attaches the `ready` listener), replay synchronously so
// App.tsx still flips `handshakeOk` and fires `list-logins`.
if (event === 'ready' && this.isReady) {
(listener as () => void)();
}
}
public sendText(body: string): Promise<{ event_id: string }> {
@ -92,6 +101,18 @@ export class WidgetApi {
}) as Promise<{ event_id: string }>;
}
// 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);
}
private emit<K extends keyof WidgetApiEvents>(
event: K,
...args: Parameters<WidgetApiEvents[K]>
@ -113,6 +134,14 @@ export class WidgetApi {
private onMessage = (ev: MessageEvent): void => {
if (ev.origin !== this.bootstrap.parentOrigin) return;
// Source-window guard: every legit widget API message comes from the
// host window that embedded our iframe — i.e. window.parent. A foreign
// tab/frame on the same origin (think browser extension content
// script, popup, or sibling iframe) could otherwise post a forged
// message that passes the origin check. We only accept messages
// whose `source` is literally `window.parent`. The `widgetId` check
// a few lines down is a soft filter; this is the hard one.
if (ev.source !== window.parent) return;
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.widgetId !== this.bootstrap.widgetId) return;

View file

@ -940,6 +940,10 @@
"show_chat": "Show chat",
"show_widget": "Show robot",
"retry_widget": "Retry robot",
"settings_label": "Settings",
"description": {
"telegram": "Matrix↔Telegram bridge. Sign in by phone number to sync your Telegram chats."
},
"unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog."
}

View file

@ -944,6 +944,10 @@
"show_chat": "Показать чат",
"show_widget": "Показать робота",
"retry_widget": "Повторить",
"settings_label": "Настроить",
"description": {
"telegram": "Мост Matrix↔Telegram. Войдите по номеру, чтобы синхронизировать чаты с Telegram."
},
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
}

View file

@ -0,0 +1,21 @@
import React from 'react';
import { RoomView } from '../room/RoomView';
type BotChatFallbackProps = {
eventId?: string;
};
// Chat-fallback body for bot DMs. Rendered by BotExperienceHost when the
// per-room `botShowChatAtomFamily` is true (user picked «Показать чат» in
// the BotShell hero menu, OR the widget failed and BotShell auto-flipped
// here). The «return to widget / retry» affordance lives as a single
// MenuItem at the top of the standard `RoomMenu` (RoomViewHeaderDm:91-122)
// — there is intentionally no floating overlay button here, so the chat
// surface looks identical to a regular DM beyond that one menu item.
//
// We only need `eventId` from the route; bot-specific context (preset,
// room) is consumed by the RoomMenu item via atom families keyed on
// roomId, not via prop drilling.
export function BotChatFallback({ eventId }: BotChatFallbackProps) {
return <RoomView eventId={eventId} />;
}

View file

@ -1,79 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Box, Button, Text } from 'folds';
import { useTranslation } from 'react-i18next';
import { RoomView } from '../room/RoomView';
import type { BotPreset } from './catalog';
import { BotWidgetHost } from './BotWidgetHost';
import * as css from './BotWidgetHost.css';
type BotExperienceSlotProps = {
preset: BotPreset;
room: Room;
eventId?: string;
};
export function BotExperienceSlot({ preset, room, eventId }: BotExperienceSlotProps) {
const { t } = useTranslation();
const [showChat, setShowChat] = useState(false);
const [failed, setFailed] = useState(false);
// Bumped on every retry so BotWidgetHost gets a fresh `key` and remounts
// even when preset/room/url are unchanged — without this, a retry after
// failure would reuse the same React element and the iframe/embed lifecycle
// would not re-run.
const [retryCount, setRetryCount] = useState(0);
const experienceUrl = preset.experience?.url;
const hasWidget = preset.experience?.type === 'matrix-widget';
const showRawChat = showChat || failed;
useEffect(() => {
setShowChat(false);
setFailed(false);
setRetryCount(0);
}, [preset.id, room.roomId, experienceUrl]);
const handleShowChat = useCallback(() => {
setShowChat(true);
}, []);
const handleWidgetError = useCallback(() => {
setFailed(true);
}, []);
const handleShowWidget = useCallback(() => {
setShowChat(false);
setFailed(false);
setRetryCount((c) => c + 1);
}, []);
if (!hasWidget) {
return <RoomView eventId={eventId} />;
}
if (showRawChat) {
return (
<Box className={css.Host}>
<Box className={css.FrameMount}>
<RoomView eventId={eventId} />
</Box>
<Box className={css.Toolbar}>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowWidget}>
<Text size="B300">{failed ? t('Bots.retry_widget') : t('Bots.show_widget')}</Text>
</Button>
</Box>
</Box>
);
}
return (
<Box className={css.Host}>
<BotWidgetHost key={retryCount} preset={preset} room={room} onError={handleWidgetError} />
<Box className={css.Toolbar}>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowChat}>
<Text size="B300">{t('Bots.show_chat')}</Text>
</Button>
</Box>
</Box>
);
}

View file

@ -0,0 +1,252 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, toRem } from 'folds';
// BotShell is the bot-page container: it OWNS the hero and the iframe
// mount. Standard Cinny `RoomViewHeader` is intentionally absent here —
// the BotsDesktop mockup (stream-v2-dawn.jsx:660-672) places the hero as
// the first row of the bot panel, with no chat-style chrome above.
// Shell bg = SurfaceVariant.Container (#181a20) — matches DAWN.bg. The widget
// iframe body uses the same #181a20 (apps/widget-telegram/src/styles.css:8).
// Mockup canon paints the bot panel on DAWN.bg sitting on a DAWN.bg2
// (#0d0e11) parent — Background.Container would invert that and produce a
// visible seam at the iframe's top edge. SurfaceVariant.Container keeps the
// hero and the iframe body on the same tone.
export const Shell = style([
DefaultReset,
{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
overflow: 'hidden',
},
]);
export const Frame = style([
DefaultReset,
{
flex: 1,
minHeight: 0,
position: 'relative',
},
]);
// Hero outer band — full-width strip carrying the border-bottom that
// separates the hero from the iframe body. Vertical padding only; the
// horizontal padding sits on `HeroInner` so the inner content can be
// constrained to the same `max-width: 960px` the widget body uses
// (apps/widget-telegram/src/styles.css:64-72), keeping the host hero's
// left/right edges aligned with the body content visible inside the
// iframe.
export const Hero = style([
DefaultReset,
{
borderBottom: `1px solid ${color.Background.ContainerLine}`,
flexShrink: 0,
padding: `${toRem(36)} 0 ${toRem(28)}`,
'@media': {
// Compact mobile band — matches the visual height of Cinny's standard
// chat header (~56-64px), per user's request «хедер по разумеру как
// чат обычный». The big desktop hero (avatar 56 + 2-line title +
// multi-line description) is too heavy on a narrow viewport.
'(max-width: 600px)': {
padding: `${toRem(8)} 0`,
},
},
},
]);
// Inner row — constrained to 960px to match the widget body. Horizontal
// padding lives here. flex row carries the back-chevron / avatar / body /
// settings-button stack.
export const HeroInner = style([
DefaultReset,
{
maxWidth: toRem(960),
margin: '0 auto',
padding: `0 ${toRem(40)}`,
display: 'flex',
alignItems: 'flex-start',
gap: toRem(18),
'@media': {
'(max-width: 600px)': {
padding: `0 ${toRem(12)}`,
gap: toRem(10),
// Single row on mobile — no wrap. Avatar + name + settings fit
// as a chat-header-like strip; handle and description are hidden
// by their own media blocks below.
alignItems: 'center',
},
},
},
]);
// Mobile-only back chevron that lives at the start of the hero row. The
// hero re-orders to flex-start on mobile (hero already wraps via the
// max-width:600px media block), so the chevron leads the row rather than
// stacking awkwardly with the avatar.
export const HeroBack = style([
DefaultReset,
{
flexShrink: 0,
alignSelf: 'center',
},
]);
// 56×56 square avatar with 14px radius, fleet violet (DAWN.fleet) bg.
// Fleet color is hardcoded here because it's the canonical bot accent in
// the mockup and we don't want it varying with Folds palette swaps.
export const HeroAvatar = style([
DefaultReset,
{
width: toRem(56),
height: toRem(56),
borderRadius: toRem(14),
backgroundColor: '#9580ff',
color: '#0c0c0e',
fontSize: toRem(24),
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
'@media': {
'(max-width: 600px)': {
width: toRem(36),
height: toRem(36),
borderRadius: toRem(8),
fontSize: toRem(16),
},
},
},
]);
export const HeroBody = style([
DefaultReset,
{
flex: 1,
minWidth: 0,
},
]);
export const HeroTitleRow = style([
DefaultReset,
{
display: 'flex',
alignItems: 'baseline',
gap: toRem(10),
marginBottom: toRem(4),
flexWrap: 'wrap',
'@media': {
'(max-width: 600px)': {
marginBottom: 0,
},
},
},
]);
export const HeroName = style([
DefaultReset,
{
fontSize: toRem(22),
fontWeight: 700,
color: color.Surface.OnContainer,
'@media': {
'(max-width: 600px)': {
// Single-line truncated name — chat-header style.
fontSize: toRem(16),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
maxWidth: '100%',
},
},
},
]);
// Handle and description are hidden on mobile to keep the hero band at
// chat-header height. The bot's identity is already conveyed by the
// avatar + name + the room route itself; the operator-config description
// is supplemental and the mxid is rarely useful on phone where you
// can't easily copy it anyway.
export const HeroHandle = style([
DefaultReset,
{
fontSize: toRem(13),
color: color.SurfaceVariant.OnContainer,
fontFamily: 'ui-monospace, "JetBrains Mono", "SF Mono", monospace',
wordBreak: 'break-all',
opacity: 0.6,
'@media': {
'(max-width: 600px)': {
display: 'none',
},
},
},
]);
export const HeroDescription = style([
DefaultReset,
{
fontSize: toRem(14),
// toRem keeps line-height in lockstep with font-size when the user
// scales root size or zooms; mixing raw px would break the 1.43 ratio.
lineHeight: toRem(20),
color: color.SurfaceVariant.OnContainer,
maxWidth: toRem(560),
'@media': {
'(max-width: 600px)': {
display: 'none',
},
},
},
]);
// Trailing "Настроить" button — overrides the mockup's transparent spec
// with a dark filled chip. Sits on top of the hero's #181a20
// (SurfaceVariant.Container) surface; Background.Container resolves to
// #0d0e11 and reads as a darker chip — the visual partner of the widget-
// side status pill that sits directly below it on the right edge.
export const HeroSettingsButton = style([
DefaultReset,
{
background: color.Background.Container,
color: color.Surface.OnContainer,
border: `1px solid ${color.Background.ContainerLine}`,
borderRadius: toRem(8),
padding: `${toRem(10)} ${toRem(18)}`,
fontSize: toRem(14),
fontWeight: 500,
cursor: 'pointer',
flexShrink: 0,
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
selectors: {
'&:hover:not(:disabled)': {
background: color.Background.ContainerHover,
borderColor: color.Background.ContainerActive,
},
'&[aria-pressed="true"]': {
background: color.Background.ContainerActive,
borderColor: color.Background.ContainerActive,
},
},
'@media': {
'(max-width: 600px)': {
padding: `${toRem(6)} ${toRem(12)}`,
fontSize: toRem(13),
fontWeight: 400,
},
},
},
]);

View file

@ -0,0 +1,53 @@
import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import type { Room } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { BotShellHero } from './BotShellHero';
import { BotWidgetMount } from './BotWidgetMount';
import { botFailedAtomFamily, botShowChatAtomFamily } from './botExperienceState';
import * as css from './BotShell.css';
type BotShellProps = {
preset: BotPreset;
room: Room;
};
// Bot-page layout. Owns the hero (host-side, mockup BotsDesktop:660-672) and
// the iframe mount. Standard Cinny `RoomViewHeader` is intentionally absent
// here — BotExperienceHost branches above `<Room>`, picking BotShell when the
// user's `botShowChatAtomFamily(roomId)` is false. The chat fallback path
// uses the regular Room layout via BotChatFallback.
//
// Implicit dependency: BotShellMenu reaches into `useRoomUnread`,
// `useRoomsNotificationPreferencesContext`, and friends — all of which
// expect a `<RoomProvider>` somewhere above. BotExperienceHost wraps both
// branches in `<BotRoomProvider>` (which mounts RoomProvider +
// IsOneOnOneProvider), so this is satisfied today. A future refactor that
// mounts BotShell standalone (e.g. a multi-bot dashboard preview) will
// fail at runtime in obscure ways unless that wrap is preserved. Don't
// move BotShell out of the BotRoomProvider context without rewiring the
// menu.
//
// Failure handling: when BotWidgetMount reports an iframe error, we set the
// per-room failure flag AND flip the show-chat atom — this bounces the user
// to the standard Room view (which lives behind the same atom) and the chat
// fallback overlay surfaces a «Retry widget» button. Clearing both atoms
// re-mounts BotShell with a fresh iframe.
export function BotShell({ preset, room }: BotShellProps) {
const setFailed = useSetAtom(botFailedAtomFamily(room.roomId));
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
const handleError = useCallback(() => {
setFailed(true);
setShowChat(true);
}, [setFailed, setShowChat]);
return (
<div className={css.Shell}>
<BotShellHero preset={preset} room={room} />
<div className={css.Frame}>
<BotWidgetMount preset={preset} room={room} onError={handleError} />
</div>
</div>
);
}

View file

@ -0,0 +1,111 @@
import React, { useState } from 'react';
import type { Room } from 'matrix-js-sdk';
import { Icon, IconButton, Icons, PopOut, RectCords } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import type { BotPreset } from './catalog';
import { BotShellMenu } from './BotShellMenu';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './BotShell.css';
type BotShellHeroProps = {
preset: BotPreset;
room: Room;
};
// Initial for the avatar block. Prefer the human name's first character
// (matches mockup BOT.name = «build-bot» → «B»); fall back to the mxid
// localpart, then to «?» when neither is usable. The previous «T» literal
// hardcoded the Telegram preset and fails the moment a second bot ships.
const heroInitial = (preset: BotPreset): string => {
const fromName = preset.name.trim().charAt(0);
if (fromName) return fromName.toUpperCase();
const local = preset.mxid.split(':')[0].replace('@', '');
return local.charAt(0).toUpperCase() || '?';
};
export function BotShellHero({ preset, room }: BotShellHeroProps) {
const { t } = useTranslation();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
// Operator override from /config.json wins; otherwise fall back to the
// localized `Bots.description.<id>` key. Empty string suppresses the
// line entirely so a missing translation doesn't ship the key.
const description = t(`Bots.description.${preset.id}`, {
defaultValue: preset.description ?? '',
});
const initial = heroInitial(preset);
return (
<header className={css.Hero}>
<div className={css.HeroInner}>
{/* Mobile back chevron bypasses Cinny's standard RoomViewHeader
* (which BotShell deliberately doesn't mount), so the user retains
* the «walk up the route tree» affordance the rest of the client
* provides on phone. Desktop relies on the sidebar to navigate. */}
{isMobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton
className={css.HeroBack}
fill="None"
onClick={onBack}
aria-label={t('Room.close')}
>
<Icon src={Icons.ChevronLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
<div className={css.HeroAvatar} aria-hidden="true">
{initial}
</div>
<div className={css.HeroBody}>
<div className={css.HeroTitleRow}>
<span className={css.HeroName}>{preset.name}</span>
<span className={css.HeroHandle}>{preset.mxid}</span>
</div>
{description ? <p className={css.HeroDescription}>{description}</p> : null}
</div>
<button
type="button"
className={css.HeroSettingsButton}
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
{t('Bots.settings_label')}
</button>
</div>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<BotShellMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</header>
);
}

View file

@ -0,0 +1,134 @@
import React, { forwardRef } from 'react';
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
import { useSetAtom } from 'jotai';
import type { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { UseStateProvider } from '../../components/UseStateProvider';
import { markAsRead } from '../../utils/notifications';
import { botShowChatAtomFamily } from './botExperienceState';
type BotShellMenuProps = {
room: Room;
requestClose: () => void;
};
// Slim dropdown for the bot hero's «Настроить» button. Only items that make
// sense in a 1:1 with a bridge bot:
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
// - Mark as read
// - Notifications (shared switcher)
// - Leave room
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
// dropped — none apply to a bridge bot's control DM.
export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
({ room, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
// Setter-only — the menu's only job around showChat is to flip it.
// useSetAtom skips the value-subscription that useAtom registers.
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
const handleShowChat = () => {
setShowChat(true);
requestClose();
};
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleShowChat}
size="300"
after={<Icon size="100" src={Icons.Message} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.show_chat')}
</Text>
</MenuItem>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);

View file

@ -49,6 +49,7 @@ const getBotWidgetUrl = (
url.searchParams.set('userId', mx.getSafeUserId());
url.searchParams.set('botId', preset.id);
url.searchParams.set('botMxid', preset.mxid);
url.searchParams.set('commandPrefix', preset.experience.commandPrefix);
url.searchParams.set('theme', theme.kind);
url.searchParams.set('clientLanguage', language);
url.searchParams.set('baseUrl', mx.baseUrl);

View file

@ -1,18 +0,0 @@
import React, { useRef } from 'react';
import { Room } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import * as css from './BotWidgetHost.css';
type BotWidgetHostProps = {
preset: BotPreset;
room: Room;
onError: () => void;
};
export function BotWidgetHost({ preset, room, onError }: BotWidgetHostProps) {
const containerRef = useRef<HTMLDivElement>(null);
useBotWidgetEmbed({ containerRef, preset, room, onError });
return <div ref={containerRef} className={css.FrameMount} />;
}

View file

@ -0,0 +1,22 @@
import React, { useRef } from 'react';
import { Room } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import * as css from './BotWidgetMount.css';
// 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
// dispatcher); «Mount» reads as the literal DOM mount point this is.
type BotWidgetMountProps = {
preset: BotPreset;
room: Room;
onError: () => void;
};
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
const containerRef = useRef<HTMLDivElement>(null);
useBotWidgetEmbed({ containerRef, preset, room, onError });
return <div ref={containerRef} className={css.FrameMount} />;
}

View file

@ -0,0 +1,20 @@
import { atomFamily } from 'jotai/utils';
import { atom } from 'jotai';
// Per-room toggle: when true, BotExperienceHost renders the standard chat
// (Room + RoomView) instead of BotShell. Owned by an atom so the «Настроить»
// dropdown in the host hero can write it without prop-drilling, and so
// remounting the page for the same room preserves the user's last selection.
//
// AtomFamily keyed by roomId — switching rooms keeps each room's toggle
// isolated. NOT auto-GC'd; for current single-bot deployments the cardinality
// is bounded. Multi-bot scope (Phase 4+) should call
// `botShowChatAtomFamily.remove(roomId)` on leave-room.
export const botShowChatAtomFamily = atomFamily((_roomId: string) => atom(false));
// Per-room sticky failure flag. Set when the widget iframe fails to load or
// errors during runtime. Held alongside `showChat` so the chat-fallback
// view can offer «Retry widget» (vs. plain «Show widget») and so the user
// can recover from a stuck state without losing context. Cleared explicitly
// on retry / clean reload of BotShell.
export const botFailedAtomFamily = atomFamily((_roomId: string) => atom(false));

View file

@ -5,6 +5,9 @@ import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
export type BotExperience = {
type: 'matrix-widget';
url: string;
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
* Resolved with the bridgev2 default `!tg` when the operator omits it. */
commandPrefix: string;
};
export type BotPreset = {
@ -13,6 +16,10 @@ export type BotPreset = {
/** Bot user mxid. The DM with this user IS the bot's control room. */
mxid: string;
name: string;
/** Optional operator override of the localized default. When present takes
* precedence over the i18n key `Bots.description.<id>`. Resolved at the
* consumer (see `useBotDescription`). */
description?: string;
experience?: BotExperience;
};
@ -28,6 +35,27 @@ const MXID_RE = /^@[^:\s]+:[^\s]+$/;
// hosts; the dev branch below bypasses this check for `http://localhost:*`.
const PROD_WIDGET_ORIGINS: ReadonlySet<string> = new Set(['https://widgets.vojo.chat']);
// bridgev2's Telegram connector ships `!tg` as DefaultCommandPrefix
// (mautrix/telegram pkg/connector/connector.go:68). Operators can override via
// `bridge.command_prefix` in the mautrix-telegram config; in that case they
// must mirror the override in /config.json so the widget prepends the right
// prefix to every outbound command.
const DEFAULT_BOT_COMMAND_PREFIX = '!tg';
// Reject whitespace and empties — the prefix is concatenated to outbound
// command bodies as `<prefix> <args>`, and bridgev2 strips exactly
// `<prefix>+" "` (queue.go:118). Whitespace inside the prefix would break the
// strip and route the message to the unknown-command fallback.
const COMMAND_PREFIX_RE = /^\S+$/;
const normalizeCommandPrefix = (raw: unknown): string | undefined => {
if (raw === undefined) return DEFAULT_BOT_COMMAND_PREFIX;
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!COMMAND_PREFIX_RE.test(trimmed)) return undefined;
return trimmed;
};
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) &&
@ -42,6 +70,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
if (type !== 'matrix-widget' || !url) return undefined;
if (url.startsWith('//')) return undefined;
// Reject the whole experience block when commandPrefix is present but
// malformed — falling back to the default would silently mask an operator
// typo in /config.json that the widget then can't recover from at runtime.
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
if (commandPrefix === undefined) return undefined;
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
@ -64,7 +98,11 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
if (!resolved.pathname.startsWith('/widgets/')) return undefined;
const lastSegment = resolved.pathname.split('/').pop() ?? '';
if (!lastSegment.includes('.')) return undefined;
return { type, url: `${resolved.pathname}${resolved.search}${resolved.hash}` };
return {
type,
url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
commandPrefix,
};
} catch {
return undefined;
}
@ -79,12 +117,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() };
return { type, url: parsed.toString(), commandPrefix };
}
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() };
return { type, url: parsed.toString(), commandPrefix };
} catch {
return undefined;
}
@ -101,10 +139,15 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
seenIds.add(preset.id);
seenMxids.add(preset.mxid);
const description =
typeof preset.description === 'string' && preset.description.trim().length > 0
? preset.description.trim()
: undefined;
bots.push({
id: preset.id,
mxid: preset.mxid,
name: preset.name.trim(),
description,
experience: normalizeBotExperience(preset.experience),
});
});
@ -112,6 +155,10 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
return bots;
};
// NOTE: description rendering lives at the call site (BotShellHero) via
// `t(\`Bots.description.${preset.id}\`, { defaultValue: preset.description ?? '' })`.
// Catalog stays config-loading-only and doesn't depend on i18next.
export const useBotPresets = (): BotPreset[] => {
const clientConfig = useClientConfig();
return useMemo(() => getBotPresets(clientConfig), [clientConfig]);

View file

@ -56,3 +56,13 @@ export const isCatalogBotControlRoom = (
room: Room,
presets: readonly BotPreset[]
): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset));
// Returns the BotPreset that owns this room, or undefined if the room is
// not a bot control DM. Used by the RoomMenu's «Show widget» item to
// know which `/bots/:id` route to navigate to. Wraps the same matcher as
// `isCatalogBotControlRoom`.
export const findBotPresetForRoom = (
mx: MatrixClient,
room: Room,
presets: readonly BotPreset[]
): BotPreset | undefined => presets.find((preset) => isBotControlRoom(mx, room, preset));

View file

@ -20,8 +20,9 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@ -80,20 +81,71 @@ import { markAsRead } from '../../utils/notifications';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { useBotPresets } from '../bots/catalog';
import { isCatalogBotControlRoom } from '../bots/room';
import { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room';
import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState';
import { getBotPath } from '../../pages/pathUtils';
import { JumpToTime } from './jump-to-time';
import { RoomPinMenu } from './room-pin-menu';
import * as css from './RoomViewHeaderDm.css';
// Single bot-aware menu item rendered at the top of RoomMenu when the
// current room is a Vojo bot control DM. Reads `botFailedAtomFamily` to
// label correctly («Retry widget» when a prior load failed, «Show widget»
// otherwise) and clears both atoms on click.
//
// IMPORTANT: this menu surfaces in BOTH `/bots/:botId` (chat-fallback) and
// `/direct/:roomId` (regular DM that happens to be a bot's control room).
// In the second case clearing the atoms alone is invisible — the user is
// not on the route that observes them. We navigate to `/bots/:botId` so
// the BotShell actually mounts.
function BotShowWidgetMenuItem({
roomId,
botId,
requestClose,
}: {
roomId: string;
botId: string;
requestClose: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const setShowChat = useSetAtom(botShowChatAtomFamily(roomId));
const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId));
const handleClick = () => {
setFailed(false);
setShowChat(false);
navigate(getBotPath(botId));
requestClose();
};
return (
<MenuItem
onClick={handleClick}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
</Text>
</MenuItem>
);
}
type RoomMenuProps = {
room: Room;
callView?: boolean;
// When true the room is a Vojo bot control DM rendered in chat-fallback
// mode. The menu prepends a «Show widget / Retry widget» item so the
// user can return to BotShell without hunting for an overlay button.
// Other items stay standard — bots in chat-fallback should look like
// normal rooms beyond that one affordance.
botControlRoom?: boolean;
onSearch: () => void;
onPin: (cords: RectCords) => void;
requestClose: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, callView, onSearch, onPin, requestClose }, ref) => {
({ room, callView, botControlRoom, onSearch, onPin, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@ -108,6 +160,11 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
const pinnedEvents = useRoomPinnedEvents(room);
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
// Look up the matching bot preset only when this menu IS the bot
// variant. The lookup walks the preset list once; cheap. Returns
// undefined for non-bot rooms — we won't render the menu item.
const bots = useBotPresets();
const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined;
const [invitePrompt, setInvitePrompt] = useState(false);
@ -148,6 +205,18 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
}}
/>
)}
{botControlRoom && botPreset && (
<>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<BotShowWidgetMenuItem
roomId={room.roomId}
botId={botPreset.id}
requestClose={requestClose}
/>
</Box>
<Line variant="Surface" size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
@ -643,6 +712,7 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
<RoomMenu
room={room}
callView={callView}
botControlRoom={isBotControlRoom}
onSearch={handleSearch}
onPin={(cords) => setPinMenuAnchor(cords)}
requestClose={() => setMenuAnchor(undefined)}

View file

@ -16,9 +16,13 @@ export type BotConfig = {
id?: string;
mxid?: string;
name?: string;
/** Optional operator override of the localized description. Falls back
* to i18n key `Bots.description.<id>` when absent. */
description?: string;
experience?: {
type?: string;
url?: string;
commandPrefix?: string;
};
};

View file

@ -2,8 +2,12 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import { Icon, Icons } from 'folds';
import { useTranslation } from 'react-i18next';
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot';
import { useAtomValue } from 'jotai';
import type { Room as MatrixRoom } from 'matrix-js-sdk';
import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog';
import { BotChatFallback } from '../../../features/bots/BotChatFallback';
import { BotShell } from '../../../features/bots/BotShell';
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
import { useBotRoom } from '../../../features/bots/useBotRoom';
import { Room } from '../../../features/room';
import { BotInvitePending } from './BotInvitePending';
@ -13,6 +17,19 @@ import { BotRoomProvider } from './BotRoomProvider';
import { BotStatePage } from './BotStatePage';
import { BotUnsafeRoom } from './BotUnsafeRoom';
// Branches between BotShell (widget mode, no Cinny header) and the standard
// Room layout (chat fallback) based on the per-room showChat atom. Lives
// inside BotRoomProvider so both arms see the same room context and so an
// atom write from BotShellMenu (via «Показать чат») is observed here
// without prop-drilling.
function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) {
const showChat = useAtomValue(botShowChatAtomFamily(room.roomId));
if (showChat) {
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
}
return <BotShell preset={preset} room={room} />;
}
export function BotExperienceHost() {
const { t } = useTranslation();
const { botId } = useParams();
@ -54,11 +71,7 @@ export function BotExperienceHost() {
return (
<BotRoomProvider room={room}>
<Room
renderRoomView={({ eventId }) => (
<BotExperienceSlot preset={preset} room={room} eventId={eventId} />
)}
/>
<BotExperienceRoute preset={preset} room={room} />
</BotRoomProvider>
);
}