diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx
index 2ceb78c0..7c94acae 100644
--- a/apps/widget-telegram/src/App.tsx
+++ b/apps/widget-telegram/src/App.tsx
@@ -1,30 +1,74 @@
-import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
+import type { Dispatch } from 'preact/hooks';
+import type { ComponentChildren } from 'preact';
import type { WidgetBootstrap } from './bootstrap';
-import { WidgetApi, buildCapabilities, type RoomEvent } from './widget-api';
-import { createT } from './i18n';
+import { WidgetApi, type RoomEvent } from './widget-api';
+import { createT, type T } from './i18n';
+import { parseReply } from './bridge-protocol/parser';
+import {
+ initialLoginState,
+ loginReducer,
+ type LoginAction,
+ type LoginErrorFlag,
+ type LoginState,
+} from './state';
// Visual canon: «Боты · Commands IDE» mockup at
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
-// function BotsDesktop (lines 651-707) — hero with 56px square avatar in
-// fleet color, 22/700 name + monospace handle, uppercase muted section
-// labels, 2-col command-card grid, monospace transcript. The widget is the
-// Telegram-bridge half of that mockup, mounted inside the chat slot.
+// function BotsDesktop (lines 651-707) — Dawn palette, fleet-violet accent,
+// 56px square avatar, monospace handles. M12 adds three inline form cards
+// (.auth-card) and a destructive logout card (.command-card.danger) inside
+// the same vocabulary.
+
+type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error';
type TranscriptLine = {
id: string;
ts: number;
- kind: 'from-bot' | 'from-user' | 'diag' | 'error';
+ kind: TranscriptKind;
text: string;
};
-type HandshakeState = 'waiting' | 'ok';
-
type Props = {
bootstrap: WidgetBootstrap;
+ // The WidgetApi is constructed in main.tsx synchronously, BEFORE React's
+ // first render, so its message listener is attached before the host's
+ // ClientWidgetApi sends its capabilities request on iframe `load`.
+ // Constructing inside a useEffect here would race with the cached-bundle
+ // case (second mount after «Show chat» → «Show widget») and silently
+ // miss the handshake. Keep the construction at module-load.
+ api: WidgetApi;
};
const TRANSCRIPT_MAX = 200;
+// 8s — see plan: hint window between submit and bot reply. SMS-side latency
+// is owned by the user (they can see the form is open and waiting).
+const STILL_WAITING_DELAY_MS = 8_000;
+
+// 30s countdown shown on the code form. The phone-form hint warns about a
+// 30-second SMS window; this is the visible counterpart on the form that
+// actually waits for the SMS to arrive.
+const CODE_COUNTDOWN_SECONDS = 30;
+
+// Inline SVG refresh icon — replaces the unicode «⟳» glyph that read more
+// like ASCII art than UI in the previous draft. Stroke-only, so it picks
+// up `currentColor` from the parent button and matches the Dawn palette
+// without a colored asset.
+const RefreshIcon = () => (
+
+);
+
+// Linkifier: matches plain http/https URLs in transcript bodies. Bot replies
+// regularly carry matrix.to URLs (success line includes one), and our M11
+// scaffold previously rendered them as inert text.
+const URL_RE = /https?:\/\/[^\s)]+/g;
+
const formatTime = (ts: number): string => {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
@@ -33,139 +77,861 @@ const formatTime = (ts: number): string => {
return `${hh}:${mm}:${ss}`;
};
-// Initial inferred from preset name: first character of the name, uppercase.
-// Falls back to "T" so the avatar never renders blank for the Telegram preset.
-const heroInitial = (name: string): string => {
- const first = name.trim().charAt(0).toUpperCase();
- return first || 'T';
+// Build transcript children from a body string with naive URL linkification.
+// We render in plaintext-with-anchors mode only; markdown formatting from the
+// bot (backticks, asterisks) shows literal — it's not load-bearing for the
+// authentication flow and rendering raw is safer than reimplementing markdown.
+const renderBody = (body: string): ComponentChildren => {
+ const out: ComponentChildren[] = [];
+ let lastIndex = 0;
+ for (const match of body.matchAll(URL_RE)) {
+ const idx = match.index ?? 0;
+ if (idx > lastIndex) out.push(body.slice(lastIndex, idx));
+ out.push(
+
+ {match[0]}
+
+ );
+ lastIndex = idx + match[0].length;
+ }
+ if (lastIndex < body.length) out.push(body.slice(lastIndex));
+ return out.length === 0 ? body : out;
};
-export function App({ bootstrap }: Props) {
+// Mask outbound secrets before they hit the local transcript. The widget
+// never *sends* the masked form — `WidgetApi.sendCommand` always carries
+// the real value over the wire. Server-side redaction handles password
+// and token; the 2FA phone code is NOT redacted upstream (bridgev2 only
+// redacts Password/Token field types — see plan «Architectural facts»
+// note 5), so the privacy hint shown after success matters.
+const redactOutbound = (body: string, kind: 'phone' | 'code' | 'password'): string => {
+ if (kind === 'phone') return body;
+ if (kind === 'code') return '••••••';
+ return '••••••••';
+};
+
+const localizeError = (err: LoginErrorFlag, t: T): string => {
+ switch (err.kind) {
+ case 'invalid_code':
+ return t('auth-error.invalid-code');
+ case 'wrong_password':
+ return t('auth-error.wrong-password');
+ case 'invalid_value':
+ return t('auth-error.invalid-value', { reason: err.reason ?? '' });
+ case 'submit_failed':
+ return t('auth-error.submit-failed', { reason: err.reason ?? '' });
+ case 'login_in_progress':
+ return t('auth-error.login-in-progress');
+ case 'max_logins':
+ return t('auth-error.max-logins', { limit: String(err.limit ?? '?') });
+ case 'unknown_command':
+ return t('auth-error.unknown-command');
+ case 'start_failed':
+ return t('auth-error.start-failed', { reason: err.reason ?? '' });
+ case 'prepare_failed':
+ return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
+ default: {
+ const exhaustive: never = err;
+ return String(exhaustive);
+ }
+ }
+};
+
+// Centralised affordance: does the current error get rendered as red
+// .auth-card-error or yellow .auth-card-warn? Telegram-side soft errors
+// (FloodWait, banned, unregistered — all funneled through submit_failed)
+// are warnings. Wrong-input errors are red.
+const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
+ if (err.kind === 'submit_failed') return 'warn';
+ if (err.kind === 'login_in_progress') return 'warn';
+ return 'error';
+};
+
+// --------------------------------------------------------------------------
+// Form components
+// --------------------------------------------------------------------------
+
+type FormProps = {
+ state: LoginState;
+ t: T;
+ dispatch: Dispatch;
+ send: (body: string, mask: 'phone' | 'code' | 'password') => Promise;
+ sendCancel: () => Promise;
+ // Phone-form cooldown plumbing — lifted to App so it survives the form's
+ // unmount on Cancel + remount on a fresh «Войти по номеру» click. Without
+ // this, a user could spam Send Code → Cancel → repeat and fire SMS at
+ // Telegram's rate-limit ceiling before bridgev2 even sees a flood.
+ phoneCooldownEnd: number | null;
+ setPhoneCooldownEnd: (ts: number | null) => void;
+};
+
+// Phone-submit cooldown — Telegram throttles repeat SMS hard, and a
+// half-second of UI lag on submit makes spamming the button trivial.
+// 60 s matches Telegram Desktop's own "Resend code" lockout.
+const PHONE_COOLDOWN_MS = 60_000;
+
+// Tick once per second while a future timestamp is still in the future.
+// Returns the seconds remaining (0 once expired). When `until` is null
+// the hook is idle.
+const useCooldownSeconds = (until: number | null): number => {
+ const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
+ const [seconds, setSeconds] = useState(compute);
+ useEffect(() => {
+ if (!until) {
+ setSeconds(0);
+ return undefined;
+ }
+ setSeconds(compute());
+ const timer = window.setInterval(() => {
+ const next = Math.max(0, Math.ceil((until - Date.now()) / 1000));
+ setSeconds(next);
+ if (next <= 0) window.clearInterval(timer);
+ }, 1000);
+ return () => window.clearInterval(timer);
+ // `compute` is referentially fresh each render but captures `until`;
+ // depending on it would re-run on every render. The effect only needs
+ // to re-run when `until` itself changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [until]);
+ return seconds;
+};
+
+const useStillWaitingHint = (deps: ReadonlyArray): boolean => {
+ const [show, setShow] = useState(false);
+ useEffect(() => {
+ setShow(false);
+ const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ // The deps array is intentionally controlled by the caller — restart
+ // the timer on every meaningful state change (e.g. submit).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ return show;
+};
+
+const PhoneForm = ({
+ state,
+ t,
+ dispatch,
+ send,
+ sendCancel,
+ phoneCooldownEnd,
+ setPhoneCooldownEnd,
+}: FormProps) => {
+ const [value, setValue] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const inputRef = useRef(null);
+ const stillWaiting = useStillWaitingHint([submitting]);
+ const cooldownSeconds = useCooldownSeconds(phoneCooldownEnd);
+ const inCooldown = cooldownSeconds > 0;
+ const error = state.kind === 'awaiting_phone' ? state.lastError : undefined;
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const onSubmit = async (event: Event) => {
+ event.preventDefault();
+ const trimmed = value.trim();
+ if (!trimmed || submitting || inCooldown) return;
+ setSubmitting(true);
+ // Clear any stale error optimistically so the form looks ready for the
+ // next attempt; a fresh error will re-arrive from the bot if the
+ // submit fails server-side.
+ dispatch({ kind: 'submit_phone' });
+ try {
+ await send(trimmed, 'phone');
+ // Cooldown locks retries ONLY after the Matrix transport accepted
+ // the message. If `await send` threw (network down, capability
+ // race, etc.), no SMS was attempted at the Telegram side — locking
+ // the form for 60s would punish the user for an issue they can fix
+ // by clicking again. The cooldown is also cleared by the App when
+ // the bot replies with `invalid_value` (malformed phone format —
+ // bridgev2 rejects before SMS dispatch); see App-level effect.
+ setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
+ } catch {
+ /* transcript carries the diagnostic; form stays open for retry */
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const tone = error ? errorTone(error) : undefined;
+ const submitDisabled = submitting || inCooldown || value.trim() === '';
+ const submitLabel = inCooldown
+ ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
+ : t('auth-card.phone.submit');
+
+ return (
+
+ );
+};
+
+// Tick-down hook for the SMS countdown. Starts on mount, decrements every
+// second until 0, holds. Returns a single number; the caller decides what
+// to render at 0 (we flip to a «didn't arrive — try again» hint).
+const useCountdown = (initialSeconds: number): number => {
+ const [remaining, setRemaining] = useState(initialSeconds);
+ useEffect(() => {
+ if (remaining <= 0) return undefined;
+ const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000);
+ return () => window.clearTimeout(timer);
+ }, [remaining]);
+ return remaining;
+};
+
+const CodeForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
+ const [value, setValue] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const inputRef = useRef(null);
+ const stillWaiting = useStillWaitingHint([submitting]);
+ // SMS countdown ticks from mount of the code form (i.e. when the bot
+ // confirmed it sent a code). Independent of `submitting` — the timer
+ // is about SMS arrival, not bot-reply latency.
+ const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS);
+ const error = state.kind === 'awaiting_code' ? state.lastError : undefined;
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const onSubmit = async (event: Event) => {
+ event.preventDefault();
+ const trimmed = value.trim();
+ if (!trimmed || submitting) return;
+ setSubmitting(true);
+ // Clear input value AND stale error optimistically — user is making a
+ // fresh attempt; the old red-state would conflict visually with the new
+ // transcript-side outcome (which may itself be a different error).
+ setValue('');
+ dispatch({ kind: 'submit_code' });
+ try {
+ await send(trimmed, 'code');
+ } catch {
+ /* transcript carries the diagnostic; form stays open for retry */
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const tone = error ? errorTone(error) : undefined;
+
+ return (
+
+ );
+};
+
+const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
+ const [value, setValue] = useState('');
+ const [reveal, setReveal] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const inputRef = useRef(null);
+ const stillWaiting = useStillWaitingHint([submitting]);
+ const error = state.kind === 'awaiting_password' ? state.lastError : undefined;
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const onSubmit = async (event: Event) => {
+ event.preventDefault();
+ if (!value || submitting) return;
+ setSubmitting(true);
+ // Capture the plaintext into a local and drop it from component state
+ // BEFORE awaiting the send. If the send throws or the form unmounts
+ // mid-flight, no plaintext lingers in React state or the DOM input —
+ // server-side redaction (bridgev2/commands/login.go:226) only fires
+ // after the message arrives, and we don't want a window where the
+ // password sits accessible to Preact Devtools / source maps.
+ const password = value;
+ setValue('');
+ dispatch({ kind: 'submit_password' });
+ try {
+ await send(password, 'password');
+ } catch {
+ /* transcript carries the diagnostic; user retypes */
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const tone = error ? errorTone(error) : undefined;
+
+ return (
+
+ );
+};
+
+// --------------------------------------------------------------------------
+// Logout card with confirm-in-place
+// --------------------------------------------------------------------------
+
+type LogoutCardProps = {
+ loginId: string | undefined;
+ t: T;
+ onConfirm: (loginId: string) => Promise;
+};
+
+const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
+ const [confirming, setConfirming] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ // Belt-and-suspenders against double-submit. `disabled={submitting}` on the
+ // button covers 99% of cases, but there's a microtask window between click
+ // and React/Preact rendering the disabled state where a fast second click
+ // could fire — the ref closes that window synchronously.
+ const inFlight = useRef(false);
+
+ if (confirming) {
+ return (
+
+
+ {t('card.logout.confirm-prompt')}
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+// --------------------------------------------------------------------------
+// Main App
+// --------------------------------------------------------------------------
+
+export function App({ bootstrap, api }: Props) {
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
- const [handshake, setHandshake] = useState('waiting');
const [transcript, setTranscript] = useState([]);
- const [pinging, setPinging] = useState(false);
- const apiRef = useRef(null);
+ const [handshakeOk, setHandshakeOk] = useState(false);
+ const apiRef = useRef(api);
const seenEventIds = useRef(new Set());
+ const [state, dispatch] = useReducer(loginReducer, initialLoginState);
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
- const capabilities = useMemo(() => buildCapabilities(bootstrap.roomId), [bootstrap.roomId]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
- const append = (line: Omit) => {
+ const append = useCallback((line: Omit) => {
setTranscript((prev) => {
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next;
});
- };
+ }, []);
+ // Pin the transcript to the bottom whenever a new line lands. Bot replies
+ // are append-only so this is the right default; users who scroll up will
+ // be snapped back on the next message — acceptable here because the
+ // transcript is a passive log, not a primary reading surface (forms and
+ // status pills are above it).
+ const transcriptRef = useRef(null);
useEffect(() => {
- const api = new WidgetApi(bootstrap, capabilities);
- apiRef.current = api;
+ const el = transcriptRef.current;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ }, [transcript.length]);
+ // App-level cooldown state for phone-form Send Code button. Lifted out of
+ // PhoneForm so it survives the form's unmount on Cancel + remount on a
+ // fresh login click — otherwise the user could spam Send→Cancel→repeat
+ // and saturate Telegram's SMS rate limit before bridgev2 reports back.
+ const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null);
+
+ // Clear the cooldown when bridge replies that our phone was malformed —
+ // `invalid_value` on the awaiting_phone form means the value was
+ // rejected at validate-time, BEFORE bridgev2 dispatched any Telegram
+ // API call. No SMS was attempted, so the 60s lock would just punish a
+ // typo. The cooldown stays in place for `submit_failed` (Telegram-side
+ // FloodWait/banned/etc — those cases SMS may have been attempted).
+ useEffect(() => {
+ if (
+ state.kind === 'awaiting_phone' &&
+ state.lastError?.kind === 'invalid_value' &&
+ phoneCooldownEnd !== null
+ ) {
+ setPhoneCooldownEnd(null);
+ }
+ }, [state, phoneCooldownEnd]);
+
+ // Subscribe to widget-api events for capability handshake completion,
+ // live events, and theme updates. The `api` itself is constructed in
+ // main.tsx BEFORE React's first render so its postMessage listener is
+ // already attached — this effect only wires React state to the api's
+ // event surface. WidgetApi.on('ready', ...) self-replays if the
+ // handshake already finished by the time we attach (cached-bundle
+ // remount: bundle parses near-instantly and the host's capabilities
+ // request can resolve before this useEffect runs).
+ useEffect(() => {
api.on('ready', () => {
- setHandshake('ok');
+ setHandshakeOk(true);
append({ kind: 'diag', text: t('diag.ready') });
+ append({ kind: 'diag', text: t('diag.checking-status') });
+ api.sendCommand('list-logins').catch((err) => {
+ append({
+ kind: 'error',
+ text: t('diag.send-failed', { message: (err as Error).message }),
+ });
+ });
});
- api.on('themeChange', (name) => {
- setTheme(name);
- });
+ api.on('themeChange', (name) => setTheme(name));
api.on('liveEvent', (ev: RoomEvent) => {
if (seenEventIds.current.has(ev.event_id)) return;
seenEventIds.current.add(ev.event_id);
+ // Defense-in-depth sender filter. Phase 1's strict 1:1 invariant
+ // already guarantees the only senders in this DM are the user and
+ // the bot, but pinning to bootstrap.botMxid covers both:
+ // (a) skip our own outbound echoes (those are appended
+ // optimistically with masking applied at click time — the live
+ // event would render the unmasked password back into the
+ // transcript), and
+ // (b) ignore any third-party noise that somehow slips past the
+ // 1:1 invariant.
+ if (ev.sender !== bootstrap.botMxid) return;
const body = ev.content.body ?? '';
- const fromUser = ev.sender === bootstrap.userId;
- append({
- kind: fromUser ? 'from-user' : 'from-bot',
- text: `${fromUser ? '→' : '←'} ${body}`,
- });
+ append({ kind: 'from-bot', text: `← ${body}` });
+
+ // Bot reply → LoginEvent → state machine. Ignore msgtype-specific
+ // routing — bridgev2 sends every login reply as m.notice; the host
+ // driver already filters to m.text/m.notice on the receive path.
+ const event = parseReply(body);
+ dispatch({ kind: 'event', event });
+
+ // After a fresh login_success the bridge doesn't send the loginId in
+ // the success line. Re-run list-logins so the reducer's `connected`
+ // state can pick up the loginId for the future logout call.
+ if (event.kind === 'login_success') {
+ api.sendCommand('list-logins').catch(() => {
+ /* surface in diag is overkill; the connected hero still works
+ without a loginId until the user clicks logout */
+ });
+ }
});
append({ kind: 'diag', text: t('diag.connecting') });
return () => {
+ // App-level unmount tears down the iframe window entirely (host
+ // detaches the iframe DOM node), so dispose just clears pending
+ // request promises. Don't null `apiRef.current` — `api` is a
+ // module-level singleton owned by main.tsx, not by this component.
api.dispose();
- apiRef.current = null;
};
- }, [bootstrap, capabilities, t]);
+ // `api`, `bootstrap`, `t`, and `append` are stable for the App's
+ // lifetime; the effect intentionally runs once at mount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- const handlePing = async () => {
- const api = apiRef.current;
- if (!api || pinging || handshake !== 'ok') return;
- setPinging(true);
- append({ kind: 'from-user', text: '→ ping' });
+ // Outbound command + transcript echo. `mask` decides what shows up in the
+ // local transcript; the wire payload always carries the real value.
+ // Errors are appended to the transcript AND rethrown — callers decide
+ // whether to roll back optimistic state transitions.
+ const send = useCallback(
+ async (body: string, mask: 'phone' | 'code' | 'password' = 'phone'): Promise => {
+ const api = apiRef.current;
+ if (!api) throw new Error('widget transport not ready');
+ append({ kind: 'from-user', text: `→ ${redactOutbound(body, mask)}` });
+ try {
+ await api.sendCommand(body);
+ } catch (err) {
+ append({
+ kind: 'error',
+ text: t('diag.send-failed', { message: (err as Error).message }),
+ });
+ throw err;
+ }
+ },
+ [append, t]
+ );
+
+ const sendBare = useCallback(
+ async (command: string): Promise => {
+ const api = apiRef.current;
+ if (!api) throw new Error('widget transport not ready');
+ append({ kind: 'from-user', text: `→ ${command}` });
+ try {
+ await api.sendCommand(command);
+ } catch (err) {
+ append({
+ kind: 'error',
+ text: t('diag.send-failed', { message: (err as Error).message }),
+ });
+ throw err;
+ }
+ },
+ [append, t]
+ );
+
+ // Optimistic transition + rollback on send failure. Cancel is panic-UX —
+ // we want immediate disconnected state. If the send fails, the bot just
+ // doesn't know we cancelled; its CommandState times out eventually.
+ const sendCancel = useCallback(async () => {
+ dispatch({ kind: 'cancel_pending' });
try {
- await api.sendText('ping');
- } catch (err) {
- append({
- kind: 'error',
- text: t('diag.send-failed', { message: (err as Error).message }),
- });
- } finally {
- // Light debounce — bridge bots flag rapid pings as flooding.
- window.setTimeout(() => setPinging(false), 1500);
+ await sendBare('cancel');
+ } catch {
+ /* already showing disconnected; transcript carries the failure */
}
+ }, [sendBare]);
+
+ // Optimistic awaiting_phone + rollback to disconnected on send failure.
+ // Without rollback the user would see the phone form open with no command
+ // ever delivered to the bot.
+ const onClickLogin = useCallback(async () => {
+ dispatch({ kind: 'start_login' });
+ try {
+ await sendBare('login phone');
+ } catch {
+ dispatch({ kind: 'cancel_pending' });
+ }
+ }, [sendBare]);
+
+ const onClickRefresh = useCallback(() => {
+ sendBare('list-logins').catch(() => {
+ /* transcript carries the failure */
+ });
+ }, [sendBare]);
+
+ // Optimistic logging_out + recovery on send failure: refire list-logins
+ // so the reducer recalibrates from the bridge's truth instead of leaving
+ // the UI stuck in logging_out forever.
+ const onConfirmLogout = useCallback(
+ async (loginId: string) => {
+ dispatch({ kind: 'request_logout', loginId });
+ try {
+ await sendBare(`logout ${loginId}`);
+ } catch {
+ sendBare('list-logins').catch(() => {
+ /* nothing more we can do — user can hit refresh */
+ });
+ }
+ },
+ [sendBare]
+ );
+
+ const formProps: FormProps = {
+ state,
+ t,
+ dispatch,
+ send,
+ sendCancel,
+ phoneCooldownEnd,
+ setPhoneCooldownEnd,
};
- const initial = heroInitial(bootstrap.botMxid.split(':')[0].replace('@', '') || 'telegram');
-
- const statusLabel = handshake === 'ok' ? t('status.ok') : t('status.waiting');
-
return (
-
-
- {initial}
-
-
-
- Telegram
- {bootstrap.botMxid}
-
-
{t('hero.description')}
-
-
-
- {statusLabel}
-
-
+ {/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
+ * longer renders an avatar/name/handle/description block — the host
+ * panel above the iframe carries that information, with the
+ * «Настроить» dropdown that controls show-chat / mark-read /
+ * notifications / leave-room. The widget body starts with the
+ * action-relevant section for the current state. */}
-
-
{t('section.check')}
-
-
+
+ ) : null}
+
+ {handshakeOk && state.kind === 'disconnected' ? (
+
+ {/* Status pill replaces the section header — the pill itself
+ * carries the section's identity («Войдите в Telegram» says
+ * what this surface is for and what state we're in, so a
+ * separate «Подключение» label was redundant). */}
+
+
+ {t('status.disconnected')}
+
+
+
+
+ {t('status.logging-out')}
+
+ {/* If the bot's `Logged out` reply never arrives, refresh
+ * fires `list-logins` and the reducer recalibrates from
+ * bridge truth (gets routed back to disconnected/connected
+ * via logins_listed/not_logged_in). Without this there's no
+ * way out of `logging_out` short of a page reload. */}
+
+
+ {t('card.refresh.label')}
+
+
+
+
+ {state.handle
+ ? t('status.connected-as', { handle: state.handle })
+ : t('status.connected')}
+
+ {/* Visible refresh when we don't yet have a loginId. The
+ * post-login_success list-logins is the normal source —
+ * if it dropped, the logout card stays disabled forever
+ * with only an invisible tooltip. Surface the recovery
+ * action explicitly so the user isn't trapped. */}
+
+
+ {t('card.refresh.label')}
+
+
))
)}
diff --git a/apps/widget-telegram/src/bootstrap.ts b/apps/widget-telegram/src/bootstrap.ts
index 0a3574b8..14c25e32 100644
--- a/apps/widget-telegram/src/bootstrap.ts
+++ b/apps/widget-telegram/src/bootstrap.ts
@@ -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 ` ` 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'),
},
diff --git a/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts b/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts
new file mode 100644
index 00000000..e4ef4d27
--- /dev/null
+++ b/apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts
@@ -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
+// `* `` () - ```.
+// Tolerate both leading-whitespace and a future fix that removes the bug.
+//
+// Name capture uses greedy `(.+)` (not `[^)]*`) because Telegram display
+// names commonly contain literal `)` — e.g. «Example (Work)», «Имя
+// (Личный)». The trailing anchor `\)\s+-\s+``` forces the regex
+// engine to backtrack to the LAST `)` before ` - `<…>``, so nested
+// parens parse correctly.
+const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
+
+// Phone prompt — bridgev2/commands/login.go:207 + connector loginphone.go:74.
+// Composed: `Please enter your \n`. Phone step
+// has no Instructions, so this is the only reply.
+const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
+
+// Code prompt — bridgev2/commands/login.go:207 + connector loginphone.go:98.
+// Same composition; sent on initial code request.
+const CODE_PROMPT_RE = /^please enter your code\b/i;
+
+// 2fa Instructions — connector login.go:170. First of TWO replies; the second
+// is `Please enter your Password` which falls into PASSWORD_REPROMPT_RE.
+const TWOFA_INSTRUCTIONS_RE = /^you have two-factor authentication enabled\.?$/i;
+
+// Password re-prompt — bridgev2/commands/login.go:207. Emitted both after
+// the 2fa instructions and after a wrong-password re-prompt.
+const PASSWORD_REPROMPT_RE = /^please enter your password\s*$/i;
+
+// Code incorrect Instructions — connector loginphone.go:107. First of two.
+const CODE_INCORRECT_RE = /^incorrect code\.?$/i;
+
+// Password incorrect Instructions — connector login.go:183. First of two.
+const PASSWORD_INCORRECT_RE = /^incorrect password,/i;
+
+// Login success — connector login.go:290. Format string is
+// `Successfully logged in as %s (\`%d\`)` — the numeric id is wrapped in
+// markdown backticks which survive into `body`. Capture both for UI use.
+const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(.+?)\s+\(`?(\d+)`?\)\.?$/i;
+
+// Logout — bridgev2/commands/login.go:591 → `Logged out` (no period).
+const LOGOUT_OK_RE = /^logged out\.?$/i;
+
+// Cancel — bridgev2/commands/processor.go:198 / 200. Action for our
+// flow is always `Login` (set by userInputLoginCommandState at login.go:218).
+const CANCEL_OK_RE = /^login cancelled\.?$/i;
+const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
+
+// Login already in progress — bridgev2/commands/login.go:83.
+const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
+
+// Max logins — bridgev2/commands/login.go:74-79. Captures the limit.
+const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
+
+// Login id not found — bridgev2/commands/login.go:587 (logout) and 68
+// (relogin). Single backtick-wrapped id capture.
+const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
+
+// Flow selector errors — bridgev2/commands/login.go:107 / 98.
+const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
+const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
+
+// Unknown command — bridgev2/commands/processor.go:163.
+const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
+
+// Generic error traps. Each anchors on a distinct prefix, so order between
+// them is incidental — kept ordered for readability.
+const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
+const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
+const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
+const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
+
+// --- Parser ---------------------------------------------------------------
+
+const trimReplyBody = (raw: string): string => {
+ // Bridge sometimes emits a leading `\n` (login-list bug, user.go:185).
+ // Trim outer whitespace before matching to keep regexes anchored on `^`.
+ return raw.trim();
+};
+
+const parseLoginList = (body: string): ListedLogin[] => {
+ const logins: ListedLogin[] = [];
+ // matchAll requires the global flag — preserve LOGIN_LIST_ROW_RE's lastIndex
+ // by rebuilding it for each call (RegExp instances are stateful with /g).
+ const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
+ for (const match of body.matchAll(re)) {
+ const [, id, name, state] = match;
+ logins.push({ id, name, state });
+ }
+ return logins;
+};
+
+export const parseGoV2604 = (rawBody: string): LoginEvent => {
+ const body = trimReplyBody(rawBody);
+ if (body.length === 0) return { kind: 'unknown' };
+
+ // Order: highly-specific terminal/transitional matches first, generic
+ // error traps last. The login-list parser comes early because its anchor
+ // (` * `` `) wouldn't false-match anything else, and the alternative
+ // — `not_logged_in` — covers the empty-list case explicitly.
+
+ if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
+
+ const successMatch = LOGIN_SUCCESS_RE.exec(body);
+ if (successMatch) {
+ return {
+ kind: 'login_success',
+ handle: successMatch[1].trim(),
+ numericId: successMatch[2],
+ };
+ }
+
+ if (TWOFA_INSTRUCTIONS_RE.test(body)) return { kind: 'twofa_required' };
+ if (CODE_INCORRECT_RE.test(body)) return { kind: 'invalid_code' };
+ if (PASSWORD_INCORRECT_RE.test(body)) return { kind: 'wrong_password' };
+
+ if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
+ if (CODE_PROMPT_RE.test(body)) return { kind: 'awaiting_code' };
+ if (PASSWORD_REPROMPT_RE.test(body)) return { kind: 'awaiting_password' };
+
+ if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
+ if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
+ if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
+ if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
+ if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
+ if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
+
+ const maxMatch = MAX_LOGINS_RE.exec(body);
+ if (maxMatch) {
+ const limit = Number(maxMatch[1]);
+ return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
+ }
+
+ const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
+ if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
+
+ const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
+ if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
+
+ const invalidValueMatch = INVALID_VALUE_RE.exec(body);
+ if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
+
+ const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
+ if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
+
+ const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
+ if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
+
+ const startFailedMatch = START_FAILED_RE.exec(body);
+ if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
+
+ // 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 ` - ```, 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);
+}
diff --git a/apps/widget-telegram/src/bridge-protocol/parser.ts b/apps/widget-telegram/src/bridge-protocol/parser.ts
new file mode 100644
index 00000000..d6bb32a0
--- /dev/null
+++ b/apps/widget-telegram/src/bridge-protocol/parser.ts
@@ -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);
diff --git a/apps/widget-telegram/src/bridge-protocol/types.ts b/apps/widget-telegram/src/bridge-protocol/types.ts
new file mode 100644
index 00000000..0ea8b863
--- /dev/null
+++ b/apps/widget-telegram/src/bridge-protocol/types.ts
@@ -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 ` 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: `. 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' };
diff --git a/apps/widget-telegram/src/i18n/en.ts b/apps/widget-telegram/src/i18n/en.ts
index 772a7368..e910271e 100644
--- a/apps/widget-telegram/src/i18n/en.ts
+++ b/apps/widget-telegram/src/i18n/en.ts
@@ -4,17 +4,62 @@
import type { StringKey } from './ru';
export const EN: Record = {
- '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}.',
diff --git a/apps/widget-telegram/src/i18n/ru.ts b/apps/widget-telegram/src/i18n/ru.ts
index a9f7f38c..08d49ea5 100644
--- a/apps/widget-telegram/src/i18n/ru.ts
+++ b/apps/widget-telegram/src/i18n/ru.ts
@@ -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}.',
diff --git a/apps/widget-telegram/src/main.tsx b/apps/widget-telegram/src/main.tsx
index 5359d0db..a597d5fa 100644
--- a/apps/widget-telegram/src/main.tsx
+++ b/apps/widget-telegram/src/main.tsx
@@ -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(, 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(, root);
}
diff --git a/apps/widget-telegram/src/state.ts b/apps/widget-telegram/src/state.ts
new file mode 100644
index 00000000..6a93c560
--- /dev/null
+++ b/apps/widget-telegram/src/state.ts
@@ -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 ()`, plus the loginId we need
+ // for `!tg logout `. 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 `.
+ 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;
+ }
+ }
+};
diff --git a/apps/widget-telegram/src/styles.css b/apps/widget-telegram/src/styles.css
index 1cfdd3f9..c75317e7 100644
--- a/apps/widget-telegram/src/styles.css
+++ b/apps/widget-telegram/src/styles.css
@@ -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 muted→amber 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 {
diff --git a/apps/widget-telegram/src/vite-env.d.ts b/apps/widget-telegram/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/apps/widget-telegram/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/widget-telegram/src/widget-api.ts b/apps/widget-telegram/src/widget-api.ts
index d3631c82..5314601b 100644
--- a/apps/widget-telegram/src/widget-api.ts
+++ b/apps/widget-telegram/src/widget-api.ts
@@ -83,6 +83,15 @@ export class WidgetApi {
public on(event: K, listener: WidgetApiEvents[K]): void {
const list = (this.listeners[event] ??= []) as Array;
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 ` ` (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(
event: K,
...args: Parameters
@@ -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;
diff --git a/public/locales/en.json b/public/locales/en.json
index 1079008d..46e9ae51 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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."
}
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 02a88abb..24e906c1 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -944,6 +944,10 @@
"show_chat": "Показать чат",
"show_widget": "Показать робота",
"retry_widget": "Повторить",
+ "settings_label": "Настроить",
+ "description": {
+ "telegram": "Мост Matrix↔Telegram. Войдите по номеру, чтобы синхронизировать чаты с Telegram."
+ },
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
}
diff --git a/src/app/features/bots/BotChatFallback.tsx b/src/app/features/bots/BotChatFallback.tsx
new file mode 100644
index 00000000..60316c21
--- /dev/null
+++ b/src/app/features/bots/BotChatFallback.tsx
@@ -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 ;
+}
diff --git a/src/app/features/bots/BotExperienceSlot.tsx b/src/app/features/bots/BotExperienceSlot.tsx
deleted file mode 100644
index 5e2b2d46..00000000
--- a/src/app/features/bots/BotExperienceSlot.tsx
+++ /dev/null
@@ -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 ;
- }
-
- if (showRawChat) {
- return (
-
-
-
-
-
-
- {failed ? t('Bots.retry_widget') : t('Bots.show_widget')}
-
-
-
- );
- }
-
- return (
-
-
-
-
- {t('Bots.show_chat')}
-
-
-
- );
-}
diff --git a/src/app/features/bots/BotShell.css.ts b/src/app/features/bots/BotShell.css.ts
new file mode 100644
index 00000000..3609b5e9
--- /dev/null
+++ b/src/app/features/bots/BotShell.css.ts
@@ -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,
+ },
+ },
+ },
+]);
diff --git a/src/app/features/bots/BotShell.tsx b/src/app/features/bots/BotShell.tsx
new file mode 100644
index 00000000..fabf9e51
--- /dev/null
+++ b/src/app/features/bots/BotShell.tsx
@@ -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 ``, 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 `` somewhere above. BotExperienceHost wraps both
+// branches in `` (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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/bots/BotShellHero.tsx b/src/app/features/bots/BotShellHero.tsx
new file mode 100644
index 00000000..de824555
--- /dev/null
+++ b/src/app/features/bots/BotShellHero.tsx
@@ -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();
+
+ const handleOpenMenu: React.MouseEventHandler = (evt) => {
+ setMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
+ // Operator override from /config.json wins; otherwise fall back to the
+ // localized `Bots.description.` 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 (
+
+
+ {/* 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 && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+ {initial}
+
+
+
+ {preset.name}
+ {preset.mxid}
+
+ {description ?
{description}
: null}
+
+
+
+ {t('Bots.settings_label')}
+
+
+
+ setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ setMenuAnchor(undefined)} />
+
+ }
+ />
+
+ );
+}
diff --git a/src/app/features/bots/BotShellMenu.tsx b/src/app/features/bots/BotShellMenu.tsx
new file mode 100644
index 00000000..4ef36bf0
--- /dev/null
+++ b/src/app/features/bots/BotShellMenu.tsx
@@ -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(
+ ({ 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 (
+
+ );
+ }
+);
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts
index 4c8ada47..87886313 100644
--- a/src/app/features/bots/BotWidgetEmbed.ts
+++ b/src/app/features/bots/BotWidgetEmbed.ts
@@ -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);
diff --git a/src/app/features/bots/BotWidgetHost.tsx b/src/app/features/bots/BotWidgetHost.tsx
deleted file mode 100644
index acfa0c2b..00000000
--- a/src/app/features/bots/BotWidgetHost.tsx
+++ /dev/null
@@ -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(null);
- useBotWidgetEmbed({ containerRef, preset, room, onError });
-
- return ;
-}
diff --git a/src/app/features/bots/BotWidgetHost.css.ts b/src/app/features/bots/BotWidgetMount.css.ts
similarity index 100%
rename from src/app/features/bots/BotWidgetHost.css.ts
rename to src/app/features/bots/BotWidgetMount.css.ts
diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx
new file mode 100644
index 00000000..6638caf3
--- /dev/null
+++ b/src/app/features/bots/BotWidgetMount.tsx
@@ -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(null);
+ useBotWidgetEmbed({ containerRef, preset, room, onError });
+
+ return ;
+}
diff --git a/src/app/features/bots/botExperienceState.ts b/src/app/features/bots/botExperienceState.ts
new file mode 100644
index 00000000..837ff317
--- /dev/null
+++ b/src/app/features/bots/botExperienceState.ts
@@ -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));
diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts
index 19d640d9..9f19fefd 100644
--- a/src/app/features/bots/catalog.ts
+++ b/src/app/features/bots/catalog.ts
@@ -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.`. 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 = 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 ``, and bridgev2 strips exactly
+// `+" "` (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]);
diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts
index efeba6c4..3ade62eb 100644
--- a/src/app/features/bots/room.ts
+++ b/src/app/features/bots/room.ts
@@ -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));
diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx
index 8db4aeea..ebfed5f5 100644
--- a/src/app/features/room/RoomViewHeaderDm.tsx
+++ b/src/app/features/room/RoomViewHeaderDm.tsx
@@ -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 (
+ }
+ radii="300"
+ >
+
+ {t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
+
+
+ );
+}
+
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(
- ({ 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(
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(
}}
/>
)}
+ {botControlRoom && botPreset && (
+ <>
+
+
+
+
+ >
+ )}