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 ( +
+
{t('auth-card.phone.title')}
+ +
+ setValue((e.currentTarget as HTMLInputElement).value)} + disabled={submitting} + /> + + +
+
{t('auth-card.phone.hint')}
+ {error ? ( +
+ {localizeError(error, t)} +
+ ) : null} + {submitting && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// Tick-down hook for the SMS countdown. Starts on mount, decrements every +// second until 0, holds. Returns a single number; the caller decides what +// to render at 0 (we flip to a «didn't arrive — try again» hint). +const useCountdown = (initialSeconds: number): number => { + const [remaining, setRemaining] = useState(initialSeconds); + useEffect(() => { + if (remaining <= 0) return undefined; + const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000); + return () => window.clearTimeout(timer); + }, [remaining]); + return remaining; +}; + +const CodeForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { + const [value, setValue] = useState(''); + const [submitting, setSubmitting] = useState(false); + const inputRef = useRef(null); + const stillWaiting = useStillWaitingHint([submitting]); + // SMS countdown ticks from mount of the code form (i.e. when the bot + // confirmed it sent a code). Independent of `submitting` — the timer + // is about SMS arrival, not bot-reply latency. + const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS); + const error = state.kind === 'awaiting_code' ? state.lastError : undefined; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const onSubmit = async (event: Event) => { + event.preventDefault(); + const trimmed = value.trim(); + if (!trimmed || submitting) return; + setSubmitting(true); + // Clear input value AND stale error optimistically — user is making a + // fresh attempt; the old red-state would conflict visually with the new + // transcript-side outcome (which may itself be a different error). + setValue(''); + dispatch({ kind: 'submit_code' }); + try { + await send(trimmed, 'code'); + } catch { + /* transcript carries the diagnostic; form stays open for retry */ + } finally { + setSubmitting(false); + } + }; + + const tone = error ? errorTone(error) : undefined; + + return ( +
+
{t('auth-card.code.title')}
+ +
+ setValue((e.currentTarget as HTMLInputElement).value)} + disabled={submitting} + /> + + +
+ {error ? ( +
+ {localizeError(error, t)} +
+ ) : null} + {/* + * SMS countdown is suppressed while the form is submitting — the + * bot-latency hint takes over once the user has typed and clicked. + * Both rendering simultaneously stacks two distinct waits (SMS + * arrival vs bot reply) on the same form and reads as clutter. + */} + {!submitting && + (countdownSeconds > 0 ? ( +
+ {t('auth-card.code.countdown', { seconds: String(countdownSeconds) })} +
+ ) : ( +
{t('auth-card.code.countdown-done')}
+ ))} + {submitting && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { + const [value, setValue] = useState(''); + const [reveal, setReveal] = useState(false); + const [submitting, setSubmitting] = useState(false); + const inputRef = useRef(null); + const stillWaiting = useStillWaitingHint([submitting]); + const error = state.kind === 'awaiting_password' ? state.lastError : undefined; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const onSubmit = async (event: Event) => { + event.preventDefault(); + if (!value || submitting) return; + setSubmitting(true); + // Capture the plaintext into a local and drop it from component state + // BEFORE awaiting the send. If the send throws or the form unmounts + // mid-flight, no plaintext lingers in React state or the DOM input — + // server-side redaction (bridgev2/commands/login.go:226) only fires + // after the message arrives, and we don't want a window where the + // password sits accessible to Preact Devtools / source maps. + const password = value; + setValue(''); + dispatch({ kind: 'submit_password' }); + try { + await send(password, 'password'); + } catch { + /* transcript carries the diagnostic; user retypes */ + } finally { + setSubmitting(false); + } + }; + + const tone = error ? errorTone(error) : undefined; + + return ( +
+
{t('auth-card.password.title')}
+
{t('auth-card.password.hint')}
+ +
+
+ setValue((e.currentTarget as HTMLInputElement).value)} + disabled={submitting} + /> + +
+ + +
+ {error ? ( +
+ {localizeError(error, t)} +
+ ) : null} + {submitting && stillWaiting ? ( +
{t('auth-card.waiting-hint')}
+ ) : null} +
+ ); +}; + +// -------------------------------------------------------------------------- +// 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 (
-
- -
-
- 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('hint.m11')}

-
+ {/* Recovery affordance — without this the user stares at the + * «Проверка статуса…» pill forever if the initial + * list-logins reply was dropped on the wire. */} + +
+ + ) : null} + + {handshakeOk && state.kind === 'disconnected' ? ( +
+ {/* Status pill 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('landing.hint')}

+
+ ) : null} + + {state.kind === 'awaiting_phone' ? ( +
+ +
+ ) : null} + {state.kind === 'awaiting_code' ? ( +
+ +
+ ) : null} + {state.kind === 'awaiting_password' ? ( +
+ +
+ ) : null} + + {state.kind === 'logging_out' ? ( +
+
+ + + {t('status.logging-out')} + + {/* If the bot's `Logged out` reply never arrives, refresh + * fires `list-logins` and the reducer recalibrates from + * bridge truth (gets routed back to disconnected/connected + * via logins_listed/not_logged_in). Without this there's no + * way out of `logging_out` short of a page reload. */} + +
+
+ ) : null} + + {state.kind === 'connected' ? ( +
+ {state.loginId ? ( + + + {state.handle + ? t('status.connected-as', { handle: state.handle }) + : t('status.connected')} + + ) : ( +
+ + + {state.handle + ? t('status.connected-as', { handle: state.handle }) + : t('status.connected')} + + {/* Visible refresh when we don't yet have a loginId. The + * post-login_success list-logins is the normal source — + * if it dropped, the logout card stays disabled forever + * with only an invisible tooltip. Surface the recovery + * action explicitly so the user isn't trapped. */} + +
+ )} +

{t('auth-card.code.privacy-hint-history')}

+
+ +
+
+ ) : null}
-
+
{transcript.length === 0 ? ( -
{t('transcript.empty')}
+
{/* placeholder kept blank intentionally */}
) : ( transcript.map((line) => (
{formatTime(line.ts)} - {line.text} + + {line.kind === 'from-bot' ? renderBody(line.text) : line.text} +
)) )} 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 ( - - - - - - - - - ); - } - - return ( - - - - - - - ); -} 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) => ( + + + + )} + + )} + +
+
+ {preset.name} + {preset.mxid} +
+ {description ?

{description}

: null} +
+ + +
+ + 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 ( + + + } + radii="300" + > + + {t('Bots.show_chat')} + + + } + radii="300" + disabled={!unread} + > + + {t('Room.mark_as_read')} + + + + {(handleOpen, opened, changing) => ( + + ) : ( + + ) + } + radii="300" + aria-pressed={opened} + onClick={handleOpen} + > + + {t('Room.notifications')} + + + )} + + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + {t('Room.leave_room')} + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); + } +); 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 && ( + <> + + + + + + )} setPinMenuAnchor(cords)} requestClose={() => setMenuAnchor(undefined)} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index f7b8dc50..808beb37 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -16,9 +16,13 @@ export type BotConfig = { id?: string; mxid?: string; name?: string; + /** Optional operator override of the localized description. Falls back + * to i18n key `Bots.description.` when absent. */ + description?: string; experience?: { type?: string; url?: string; + commandPrefix?: string; }; }; diff --git a/src/app/pages/client/bots/BotExperienceHost.tsx b/src/app/pages/client/bots/BotExperienceHost.tsx index 777c9c34..19bf4cd3 100644 --- a/src/app/pages/client/bots/BotExperienceHost.tsx +++ b/src/app/pages/client/bots/BotExperienceHost.tsx @@ -2,8 +2,12 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { Icon, Icons } from 'folds'; import { useTranslation } from 'react-i18next'; -import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog'; -import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot'; +import { useAtomValue } from 'jotai'; +import type { Room as MatrixRoom } from 'matrix-js-sdk'; +import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog'; +import { BotChatFallback } from '../../../features/bots/BotChatFallback'; +import { BotShell } from '../../../features/bots/BotShell'; +import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState'; import { useBotRoom } from '../../../features/bots/useBotRoom'; import { Room } from '../../../features/room'; import { BotInvitePending } from './BotInvitePending'; @@ -13,6 +17,19 @@ import { BotRoomProvider } from './BotRoomProvider'; import { BotStatePage } from './BotStatePage'; import { BotUnsafeRoom } from './BotUnsafeRoom'; +// Branches between BotShell (widget mode, no Cinny header) and the standard +// Room layout (chat fallback) based on the per-room showChat atom. Lives +// inside BotRoomProvider so both arms see the same room context and so an +// atom write from BotShellMenu (via «Показать чат») is observed here +// without prop-drilling. +function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) { + const showChat = useAtomValue(botShowChatAtomFamily(room.roomId)); + if (showChat) { + return } />; + } + return ; +} + export function BotExperienceHost() { const { t } = useTranslation(); const { botId } = useParams(); @@ -54,11 +71,7 @@ export function BotExperienceHost() { return ( - ( - - )} - /> + ); }