feat(discord-widget): render Open-in-Channels card after login via VOJO-LOGIN-SPACE-V1 sentinel and generic open-matrix-to widget action

This commit is contained in:
v.lagerev 2026-05-21 14:08:50 +03:00
parent a62761af06
commit f104bdfe8b
11 changed files with 347 additions and 85 deletions

View file

@ -95,6 +95,18 @@ const LinkIcon = () => (
</svg> </svg>
); );
// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
// «space with channels inside»; consistent visual vocabulary with the
// channels-tab workspace grid affordances on the host side.
const SpaceGridIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
</svg>
);
// Linkifier — same heuristic as TG widget. // Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g; const URL_RE = /https?:\/\/[^\s)]+/g;
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
`script[src^="https://js.hcaptcha.com/1/api.js"]` `script[src^="https://js.hcaptcha.com/1/api.js"]`
) as HTMLScriptElement | null; ) as HTMLScriptElement | null;
// `timeoutHandle` is read in the `settle` closure declared below
// BEFORE the assignment at the bottom of this function. ESLint's
// flow analysis can't see the deferred assignment through the
// closure and flags this as never-reassigned; in practice the value
// IS reassigned and using `const` here would break the hcaptcha
// script-load timeout path.
// eslint-disable-next-line prefer-const
let timeoutHandle: number | undefined; let timeoutHandle: number | undefined;
let settled = false; let settled = false;
const settle = (action: () => void) => { const settle = (action: () => void) => {
@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div> <div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
<div class="auth-card-captcha-frame"> <div class="auth-card-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" /> <div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? ( {loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
</div> </div>
<div class="auth-card-row"> <div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}> <button type="button" class="btn-text" onClick={onCancel}>
@ -764,6 +781,40 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
); );
}; };
// --------------------------------------------------------------------------
// Open-space card — Vojo extension
// --------------------------------------------------------------------------
type OpenSpaceCardProps = {
t: T;
matrixToUrl: string;
onOpen: (url: string) => void;
};
// Surfaces the personal Discord space the bridge auto-created at login.
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
// `space_ready` reducer case never fires).
//
// Click hands the URL to the host via the `io.vojo.bot-widget`
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
// can't navigate cinny itself. Host validates and routes.
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
<span class="command-card-lead-icon" aria-hidden="true">
<SpaceGridIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.open-space.name')}</div>
<div class="command-card-desc">{t('card.open-space.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Main App // Main App
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
// hydrate too; the live path treats it identically. // hydrate too; the live path treats it identically.
append({ kind: 'diag', text: t('diag.captcha-issued') }); append({ kind: 'diag', text: t('diag.captcha-issued') });
appendedAnyHistory = true; appendedAnyHistory = true;
} else if (parsed.kind === 'space_ready') {
// VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
// machine-readable, never user-readable. Suppress the raw
// body from the transcript and emit a diag breadcrumb
// instead so a reload-replay shows «space ready» rather
// than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
// verbatim. Same discipline as the captcha branch above.
append({ kind: 'diag', text: t('diag.space-ready') });
appendedAnyHistory = true;
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
// m.text / m.notice — body is safe to replay verbatim, // m.text / m.notice — body is safe to replay verbatim,
// BUT we still scrub any login-URL-shaped substring as // BUT we still scrub any login-URL-shaped substring as
@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) {
append({ kind: 'diag', text: t('diag.qr-issued') }); append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') { } else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current; const liveState = stateRef.current;
if ( if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
append({ kind: 'diag', text: t('diag.qr-consumed') }); append({ kind: 'diag', text: t('diag.qr-consumed') });
} }
} else if (event.kind === 'captcha_challenge') { } else if (event.kind === 'captcha_challenge') {
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
// transcript DOM (where screenshots / accessibility tools could // transcript DOM (where screenshots / accessibility tools could
// leak them). Diag-only display. // leak them). Diag-only display.
append({ kind: 'diag', text: t('diag.captcha-issued') }); append({ kind: 'diag', text: t('diag.captcha-issued') });
} else if (event.kind === 'space_ready') {
// Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
// readable and pointless to show verbatim. Emit a diag breadcrumb;
// the actual «Open in Channels» card is rendered by the reducer
// attaching `spaceMatrixToUrl` to the connected state.
append({ kind: 'diag', text: t('diag.space-ready') });
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
const body = ev.content.body ?? ''; const body = ev.content.body ?? '';
append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` }); append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` });
@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) {
// entry, but a manual disconnect path could leave us in connected // entry, but a manual disconnect path could leave us in connected
// and trigger reconnect from there). // and trigger reconnect from there).
const handle = const handle =
state.kind === 'connected_dead' || state.kind === 'connected' state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
? state.handle
: undefined;
dispatch({ kind: 'request_reconnect', handle }); dispatch({ kind: 'request_reconnect', handle });
try { try {
await sendBare('reconnect'); await sendBare('reconnect');
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
} }
/> />
<div class="command-grid"> <div class="command-grid">
{/* Open-space CTA only against a Vojo-patched bridge that
* emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
* fresh post-login user sees «next step» before the Logout
* destructive action. */}
{state.spaceMatrixToUrl ? (
<OpenSpaceCard
t={t}
matrixToUrl={state.spaceMatrixToUrl}
onOpen={(url) => api.openMatrixToUrl(url)}
/>
) : null}
<LogoutCard t={t} onConfirm={onConfirmLogout} /> <LogoutCard t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} /> <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div> </div>

View file

@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1'; const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/; const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
// Vojo-patched bridge emits this sentinel right after «Successfully logged
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
// matrix.to URL of the user's personal Discord space so the widget can
// render a CTA. Same markdown-inert + structured-JSON discipline as the
// captcha sentinel above; the bridge sends this via SendMessageEvent to
// bypass goldmark round-trip.
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED // Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login // upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// instead». Kept so a deployment running unpatched bridge still produces a // instead». Kept so a deployment running unpatched bridge still produces a
@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
} }
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
// Vojo login-space sentinel: structured JSON with the personal Discord
// space's matrix.to URL. Checked alongside the captcha sentinel —
// markdown-inert prefix means it lands verbatim from the bridge, parsed
// into a `space_ready` event for the reducer to attach to connected state.
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
// silently dropped as `unknown` rather than surfacing a stale CTA.
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
if (match) {
try {
const payload = JSON.parse(match[1]) as Record<string, unknown>;
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
if (matrixToUrl) {
return { kind: 'space_ready', matrixToUrl };
}
} catch {
// fall through — malformed payload is treated as unknown
}
}
return { kind: 'unknown' };
}
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() }; if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
@ -247,8 +278,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
typeof event.redacts === 'string' typeof event.redacts === 'string'
? event.redacts ? event.redacts
: isObject(event.content) && typeof event.content.redacts === 'string' : isObject(event.content) && typeof event.content.redacts === 'string'
? event.content.redacts ? event.content.redacts
: undefined; : undefined;
if (!target) return { kind: 'unknown' }; if (!target) return { kind: 'unknown' };
return { kind: 'qr_redacted', redactsEventId: target }; return { kind: 'qr_redacted', redactsEventId: target };
} }
@ -330,20 +361,11 @@ function runSanityChecks(): void {
// Login success (post-QR scan). No snowflake in this line; App fires // Login success (post-QR scan). No snowflake in this line; App fires
// `ping` afterwards to pick up the discordId. // `ping` afterwards to pick up the discordId.
[ ['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
'Successfully logged in as @example', ['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
{ kind: 'login_success', handle: 'example' },
],
[
'Successfully logged in as @user.name',
{ kind: 'login_success', handle: 'user.name' },
],
// Login failure paths. // Login failure paths.
[ ['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
'Error logging in: rate limited 429',
{ kind: 'login_failed', reason: 'rate limited 429' },
],
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
// unpatched upstream v0.7.6. // unpatched upstream v0.7.6.
[ [
@ -387,10 +409,7 @@ function runSanityChecks(): void {
// Logout. // Logout.
['Logged out successfully.', { kind: 'logout_ok' }], ['Logged out successfully.', { kind: 'logout_ok' }],
[ ["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
"You weren't logged in, but data was re-cleared just to be safe.",
{ kind: 'logout_no_op' },
],
// Disconnect / reconnect. // Disconnect / reconnect.
['Successfully disconnected', { kind: 'disconnect_ok' }], ['Successfully disconnected', { kind: 'disconnect_ok' }],
@ -521,7 +540,9 @@ function runSanityChecks(): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected }); console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
throw new Error( throw new Error(
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}` `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
event.content?.msgtype ?? '<none>'
}`
); );
} }
} }

View file

@ -113,6 +113,15 @@ export type LoginEvent =
| { kind: 'reconnect_no_op' } | { kind: 'reconnect_no_op' }
| { kind: 'reconnect_failed'; reason?: string } | { kind: 'reconnect_failed'; reason?: string }
// --- Vojo: bridge-managed personal space ---------------------------------
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
// separate m.notice right after the «Successfully logged in» line. Carries
// a `matrix.to` URL pointing at the user's auto-created Discord space
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
// an «Open in Channels» card; click → host navigates cinny to the space.
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
| { kind: 'space_ready'; matrixToUrl: string }
// --- bridge-side errors -------------------------------------------------- // --- bridge-side errors --------------------------------------------------
// Generic «I don't know that command» — should not happen since we only // Generic «I don't know that command» — should not happen since we only
// ship known commands, but visible if the bridge image is misconfigured // ship known commands, but visible if the bridge image is misconfigured

View file

@ -55,13 +55,11 @@ export const EN: Record<StringKey, string> = {
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bots chat.', 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bots chat.',
'auth-error.captcha-send-failed': 'auth-error.captcha-send-failed':
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.', 'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
'auth-error.captcha-expired': 'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.login-failed': 'Sign-in failed: {reason}', 'auth-error.login-failed': 'Sign-in failed: {reason}',
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}', 'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
'auth-error.connect-after-login-failed': 'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
'Signed in, but could not connect to Discord: {reason}',
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.', 'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
'auth-error.unknown-command': 'auth-error.unknown-command':
'The bot does not recognise this command — check the prefix in config.json.', 'The bot does not recognise this command — check the prefix in config.json.',
@ -73,6 +71,9 @@ export const EN: Record<StringKey, string> = {
'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel', 'card.logout.confirm-no': 'Cancel',
'card.open-space.name': 'Open in Channels',
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
'diag.space-ready': 'Discord space ready to open.',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.', 'diag.ready': 'Ready to send commands.',
'diag.checking-status': 'Checking connection status…', 'diag.checking-status': 'Checking connection status…',

View file

@ -86,8 +86,7 @@ export const RU = {
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
'auth-error.captcha-send-failed': 'auth-error.captcha-send-failed':
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.', 'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
'auth-error.captcha-expired': 'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'auth-error.login-failed': 'Не удалось войти: {reason}', 'auth-error.login-failed': 'Не удалось войти: {reason}',
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
@ -106,7 +105,11 @@ export const RU = {
'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена', 'card.logout.confirm-no': 'Отмена',
// --- Open Discord space (Vojo bridge sentinel) ------------------------
'card.open-space.name': 'Открыть в Каналах',
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
// --- Diagnostics in transcript ---------------------------------------- // --- Diagnostics in transcript ----------------------------------------
'diag.space-ready': 'Discord-спейс готов к открытию.',
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
'diag.ready': 'Готов отправлять команды.', 'diag.ready': 'Готов отправлять команды.',
'diag.checking-status': 'Проверяю статус подключения…', 'diag.checking-status': 'Проверяю статус подключения…',

View file

@ -104,8 +104,13 @@ export type LoginState =
| { kind: 'reconnecting'; handle?: string } | { kind: 'reconnecting'; handle?: string }
// Live session — ping or login_success confirmed. Discord legacy bridge // Live session — ping or login_success confirmed. Discord legacy bridge
// doesn't have a per-account loginId concept (single Discord account // doesn't have a per-account loginId concept (single Discord account
// per Matrix user), so logout doesn't need an id. // per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
| { kind: 'connected'; handle: string; discordId?: string } // is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
// right after login_success; it survives the post-login re-ping and the
// reconnect-ok transitions so the «Open in Channels» card stays visible
// until logout. Absent until the sentinel arrives (and absent forever
// against an UNPATCHED bridge — the card simply never appears).
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
// ping says we have a token but the connection's down. Status pill: // ping says we have a token but the connection's down. Status pill:
// green-ish but with a Reconnect recovery action exposed. The reducer // green-ish but with a Reconnect recovery action exposed. The reducer
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored` // distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
@ -120,10 +125,7 @@ export type LoginState =
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but // staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
// often valid for a couple of minutes — fresh enough to reuse). Other // often valid for a couple of minutes — fresh enough to reuse). Other
// transient states (logging_out, reconnecting) deliberately don't survive. // transient states (logging_out, reconnecting) deliberately don't survive.
export type HydrateRestoredState = export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
| PendingFormState
| CaptchaSolveState
| { kind: 'qr_verifying' };
// Outbound user actions the App dispatches. Form-submit actions clear any // Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions optimistically advance state — // pending lastError; structural transitions optimistically advance state —
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
const isCaptchaAcceptingState = ( const isCaptchaAcceptingState = (
s: LoginState s: LoginState
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState => ): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
s.kind === 'awaiting_qr_scan' || s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
s.kind === 'qr_verifying' ||
s.kind === 'awaiting_captcha_solve';
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'hydrate') { if (action.kind === 'hydrate') {
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
case 'logged_in': case 'logged_in':
// Authoritative source — accept from any state. Used by both the // Authoritative source — accept from any state. Used by both the
// initial ping AND the post-`login_success` re-ping that picks up // initial ping AND the post-`login_success` re-ping that picks up
// the discordId snowflake. // the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
// `connected` so the post-login_success re-ping doesn't blank the
// CTA before the user gets a chance to click it.
return { return {
kind: 'connected', kind: 'connected',
handle: event.handle, handle: event.handle,
discordId: event.discordId, discordId: event.discordId,
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
}; };
case 'connection_dead': case 'connection_dead':
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
// green with an empty handle, which the UI's // green with an empty handle, which the UI's
// `state.handle ? connected-as : connected` ternary tolerates. // `state.handle ? connected-as : connected` ternary tolerates.
// This avoids the `unknown` flap that the previous draft would // This avoids the `unknown` flap that the previous draft would
// produce when no handle was stashed. // produce when no handle was stashed. spaceMatrixToUrl is not
// restorable from connected_dead (the dead state never carried it),
// so the CTA stays hidden until a fresh sentinel arrives — bridge
// does NOT re-emit on reconnect, but the card returns once the user
// explicitly re-logs in.
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
return { kind: 'connected', handle: state.handle ?? '' }; return { kind: 'connected', handle: state.handle ?? '' };
} }
return state; return state;
case 'space_ready':
// Vojo-patched bridge surfaced the personal Discord space — attach
// its matrix.to URL to the connected state so the «Open in Channels»
// card renders. Late-arriving sentinels from an abandoned flow drop
// here silently (e.g. a sentinel that lands during `logging_out`
// mustn't resurrect a connected state). Honour only from the
// canonical alive states.
if (state.kind === 'connected') {
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
}
return state;
case 'reconnect_failed': case 'reconnect_failed':
if (state.kind !== 'reconnecting') return state; if (state.kind !== 'reconnecting') return state;
// Roll back to connected_dead carrying the previous handle. The // Roll back to connected_dead carrying the previous handle. The
@ -565,10 +584,7 @@ type HydrateAccumulator = {
terminated: boolean; terminated: boolean;
}; };
const stepHydrate = ( const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
prevAcc: HydrateAccumulator,
input: HydrateInput
): HydrateAccumulator => {
const { ev, ts } = input; const { ev, ts } = input;
// After a terminal event we normally stop — except if a fresh // After a terminal event we normally stop — except if a fresh
@ -693,9 +709,12 @@ const stepHydrate = (
case 'already_logged_in': case 'already_logged_in':
case 'unknown': case 'unknown':
case 'space_ready':
// Soft no-op for hydrate. already_logged_in is a live-flow warning // Soft no-op for hydrate. already_logged_in is a live-flow warning
// that doesn't reflect persistent state; unknown is a wording-drift // that doesn't reflect persistent state; unknown is a wording-drift
// catch-all. // catch-all; space_ready is a post-terminal sentinel — hydrate
// terminates on login_success and lets live ping reconcile, so
// the URL gets attached on the live path, not here.
return acc; return acc;
default: { default: {

View file

@ -125,6 +125,27 @@ export class WidgetApi {
); );
} }
// Ask the host to navigate to a matrix.to URL inside the cinny app
// (room or space). Same side-channel pattern as `openExternalUrl` —
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
// ignorant of this Vojo extension. The host validates the URL via
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
// BEFORE routing into the react-router; sending anything that isn't a
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
// host side. The widget is responsible for only invoking this when it
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
// sentinel).
public openMatrixToUrl(url: string): void {
window.parent.postMessage(
{
api: 'io.vojo.bot-widget',
action: 'open-matrix-to',
data: { url },
},
this.bootstrap.parentOrigin
);
}
// Always prefix outbound commands with `<commandPrefix> ` (trailing space). // Always prefix outbound commands with `<commandPrefix> ` (trailing space).
// Legacy mautrix-discord routes management-room commands through the // Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the // bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -18,6 +18,7 @@ import {
} from 'matrix-widget-api'; } from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme'; import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor'; import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { import {
BotWidgetDriver, BotWidgetDriver,
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
language: string; language: string;
onError: (error: Error) => void; onError: (error: Error) => void;
onReady?: () => void; onReady?: () => void;
// Optional generic «navigate cinny to a matrix.to room/alias» callback.
// Plumbed from `BotWidgetMount` where react-router's `useNavigate` is
// available. The embed validates the URL via `parseMatrixToRoom` BEFORE
// calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}`
// and is free to assume the inputs are well-formed Matrix references. Not
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
// (`open-matrix-to` action) reaches the same handler.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
}; };
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev); this.feedStateUpdate(ev);
}; };
// Side-channel postMessage handler for the widget's `openExternalUrl` // Side-channel postMessage handler for the widget's Vojo-extension
// call. Distinct from matrix-widget-api's `fromWidget` channel // actions. Distinct from matrix-widget-api's `fromWidget` channel
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path. // of our extension and avoids the «unknown action» reply path.
// //
// Why this exists: the host's global `setupExternalLinkHandler` // Two actions today:
// (utils/capacitor.ts) intercepts `<a target="_blank">` clicks at
// the host document level and routes them via Capacitor's Browser
// plugin. But cross-origin iframes don't bubble click events into
// the parent document, so widget-side links are invisible to it —
// on Capacitor's Android WebView those clicks silently disappear.
// The widget posts this message; we validate the URL and forward
// to the same `openExternalUrl` helper the host uses elsewhere.
// //
// Security gates (defence in depth): // * `open-external-url` — forwards an https:// URL to the host's
// `openExternalUrl` (utils/capacitor.ts), which routes through
// Capacitor's Browser plugin on native and `window.open` on web.
// Exists because cross-origin iframes don't bubble click events
// to the host document, so the global `setupExternalLinkHandler`
// never sees widget-side `<a target="_blank">` clicks — on
// Capacitor's Android WebView those would silently disappear.
//
// * `open-matrix-to` — generic «navigate cinny to a matrix.to room
// or alias». Validates the URL through the same `parseMatrixToRoom`
// cinny uses for in-app mention rendering, then hands the parsed
// `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
// widget never sees a route — it only knows matrix.to URLs.
//
// Security gates (defence in depth, apply to BOTH actions):
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
// check, a compromised widget bundle could `window.location.href // check, a compromised widget bundle could `window.location.href
// = 'https://attacker.example/'` — the browser keeps the same // = 'https://attacker.example/'` — the browser keeps the same
@ -242,11 +259,17 @@ export class BotWidgetEmbed {
// iframe of the SAME origin — e.g. an ad embed loaded into a // iframe of the SAME origin — e.g. an ad embed loaded into a
// sibling frame on the same origin in a future deployment — // sibling frame on the same origin in a future deployment —
// could otherwise pass the origin check). // could otherwise pass the origin check).
// 3. Only https URLs are honoured. We tightened from http+https to //
// https-only because no shipped widget content links over plain // Per-action URL validation (NOT shared, but each branch enforces):
// http; rejecting http closes a cleartext-redirect vector via // * `open-external-url` — requires `https:` protocol, rejecting plain
// Capacitor `Browser.open` on Android. // http, javascript:, data:, file:, etc. We tightened from http+https
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3). // to https-only because no shipped widget content links over plain
// http; rejecting http closes a cleartext-redirect vector via
// Capacitor `Browser.open` on Android.
// * `open-matrix-to` — requires the URL to parse as a matrix.to room
// or alias via `parseMatrixToRoom`. Anything else (matrix.to user
// links, event links, arbitrary https URLs, javascript:/data:/file:
// pseudo-schemes) returns undefined and silently no-ops.
private readonly onWidgetMessage = (ev: MessageEvent) => { private readonly onWidgetMessage = (ev: MessageEvent) => {
if (ev.origin !== this.widgetOrigin) return; if (ev.origin !== this.widgetOrigin) return;
if (ev.source !== this.iframe.contentWindow) return; if (ev.source !== this.iframe.contentWindow) return;
@ -255,18 +278,38 @@ export class BotWidgetEmbed {
| undefined; | undefined;
if (!msg || typeof msg !== 'object') return; if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return; if (msg.api !== 'io.vojo.bot-widget') return;
if (msg.action !== 'open-external-url') return;
const url = msg.data?.url; const url = msg.data?.url;
if (typeof url !== 'string') return; if (typeof url !== 'string') return;
try {
const parsed = new URL(url); if (msg.action === 'open-external-url') {
if (parsed.protocol !== 'https:') return; try {
} catch { const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
return; return;
} }
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */ if (msg.action === 'open-matrix-to') {
}); // Generic «navigate cinny to a matrix.to room/alias». Not bot-aware —
// the widget hands over a matrix.to URL it obtained however (parsed
// from a bridge sentinel, scraped from chat, whatever), and we
// validate via the same `parseMatrixToRoom` cinny uses for in-app
// mention rendering (plugins/react-custom-html-parser.tsx). Only the
// matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user
// links, event links, non-matrix.to URLs, javascript:/data:/etc. all
// return undefined and silently no-op here. The host-side router
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
// paths that don't provide a callback (e.g. future test harness) get
// a silent drop, not a crash.
const parsed = parseMatrixToRoom(url);
if (!parsed) return;
this.options.onOpenMatrixToRoom?.(parsed);
}
}; };
public constructor(private readonly options: BotWidgetEmbedOptions) { public constructor(private readonly options: BotWidgetEmbedOptions) {

View file

@ -1,8 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Room, SyncState } from 'matrix-js-sdk'; import { Room, SyncState } from 'matrix-js-sdk';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import {
getCanonicalAliasOrRoomId,
getCanonicalAliasRoomId,
isRoomAlias,
} from '../../utils/matrix';
import { getChannelsSpacePath } from '../../pages/pathUtils';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import * as css from './BotWidgetMount.css'; import * as css from './BotWidgetMount.css';
@ -34,15 +42,46 @@ type BotWidgetMountProps = {
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError }); const navigate = useNavigate();
const mx = useMatrixClient();
// Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any
// widget that posts `{action: 'open-matrix-to', data: {url}}` on the
// `io.vojo.bot-widget` side-channel reaches this. The embed has already
// validated the URL via `parseMatrixToRoom` so `target` is well-formed.
// For an alias we resolve to the canonical room id first — the channels
// path expects an id-or-alias either way, but joined-room lookup needs
// the id form for the via-server hint to be effective. `viaServers` are
// currently dropped (the channels view doesn't propagate them); add a
// dedicated «join-via» path if a future widget needs to surface a room
// the user hasn't joined yet.
const handleOpenMatrixToRoom = useCallback(
(target: MatrixToRoom) => {
const { roomIdOrAlias } = target;
const idOrAlias = isRoomAlias(roomIdOrAlias)
? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias
: roomIdOrAlias;
const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias);
navigate(getChannelsSpacePath(canonical));
},
[mx, navigate]
);
const { ready } = useBotWidgetEmbed({
containerRef,
preset,
room,
onError,
onOpenMatrixToRoom: handleOpenMatrixToRoom,
});
// Track Matrix sync state so the bot loading bar yields to the global // Track Matrix sync state so the bot loading bar yields to the global
// SyncIndicator when the connection is unhealthy. Without this, on a // SyncIndicator when the connection is unhealthy. Without this, on a
// dropped network the user would see TWO sweeping bars at once — the // dropped network the user would see TWO sweeping bars at once — the
// bot bar at top stuck in «still loading» plus the SyncIndicator at // bot bar at top stuck in «still loading» plus the SyncIndicator at
// bottom in transient/error state. The bottom bar is the canonical // bottom in transient/error state. The bottom bar is the canonical
// connection-state surface; the top one defers. // connection-state surface; the top one defers. Reuses `mx` from the
const mx = useMatrixClient(); // navigate-callback block above — single hook call per render.
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState()); const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
useSyncState( useSyncState(
mx, mx,
@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
// SyncIndicator can take over without two bars overlapping. // SyncIndicator can take over without two bars overlapping.
// Reduced-motion: animation is off (no iterations ever land), so // Reduced-motion: animation is off (no iterations ever land), so
// parking a static stripe for ~2s isn't graceful, just stuck. // parking a static stripe for ~2s isn't graceful, just stuck.
if ( if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
hideReason === 'sync' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setVisible(false); setVisible(false);
setPendingHide(false); setPendingHide(false);
return undefined; return undefined;

View file

@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Theme, useTheme } from '../../hooks/useTheme'; import { Theme, useTheme } from '../../hooks/useTheme';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { BotWidgetEmbed } from './BotWidgetEmbed'; import { BotWidgetEmbed } from './BotWidgetEmbed';
@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = {
preset: BotPreset; preset: BotPreset;
room: Room; room: Room;
onError: () => void; onError: () => void;
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
// react-router context is available — the hook stays unaware of routing.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
}; };
type UseBotWidgetEmbedResult = { type UseBotWidgetEmbedResult = {
@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({
preset, preset,
room, room,
onError, onError,
onOpenMatrixToRoom,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({
themeRef.current = theme; themeRef.current = theme;
const languageRef = useRef<string>(i18n.language); const languageRef = useRef<string>(i18n.language);
languageRef.current = i18n.language; languageRef.current = i18n.language;
// Same indirection for `onOpenMatrixToRoom`: the callback identity
// typically changes per render (closes over `navigate`/`mx`), and we do
// NOT want that to remount the embed. The ref carries the latest fn; the
// embed only sees a stable shim that re-reads it.
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
// Depend on primitive identity for the embed lifecycle — using `preset` // Depend on primitive identity for the embed lifecycle — using `preset`
// directly would remount the iframe (and re-handshake with the widget) // directly would remount the iframe (and re-handshake with the widget)
@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({
language: languageRef.current, language: languageRef.current,
onError, onError,
onReady: () => setReady(true), onReady: () => setReady(true),
// Indirection so the embed lifecycle doesn't reset when the
// navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
}); });
embedRef.current = embed; embedRef.current = embed;
} catch (error) { } catch (error) {

View file

@ -41,20 +41,43 @@ export type MatrixToRoomEvent = MatrixToRoom & {
const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/; const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href); export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href);
// Matrix room IDs start with `!` (and aliases with `#`) — characters that
// some URL builders percent-encode in path segments. Go's `id.MatrixURI`
// builder (mautrix-go id/matrixuri.go) uses `url.PathEscape`, which emits
// `%21` for `!` — so every matrix.to URL produced by a mautrix bridge
// arrives here as `https://matrix.to/#/%21abc:server`. Our regexes below
// match literal `!`/`#` only, so without a decode pass every bridge-
// generated permalink would silently fail to parse — both the in-chat
// linkifier (`plugins/react-custom-html-parser.tsx`) and the widget
// «open-matrix-to» action would drop the URL on the floor.
//
// Element Web does the same `decodeURIComponent` step before parsing in
// `apps/web/src/utils/permalinks/Permalinks.ts::parsePermalink`; we
// mirror that contract here. `decodeURIComponent` throws synchronously on
// malformed `%XX` sequences (e.g. lone `%`), so wrap it; a malformed URL
// is dropped the same way as a non-matching one (undefined).
const tryDecodeHref = (href: string): string => {
try {
return decodeURIComponent(href);
} catch {
return href;
}
};
const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/; const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/;
const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/; const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
const MATRIX_TO_ROOM_EVENT = const MATRIX_TO_ROOM_EVENT =
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/; /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
export const parseMatrixToUser = (href: string): string | undefined => { export const parseMatrixToUser = (href: string): string | undefined => {
const match = href.match(MATRIX_TO_USER); const match = tryDecodeHref(href).match(MATRIX_TO_USER);
if (!match) return undefined; if (!match) return undefined;
const userId = match[1]; const userId = match[1];
return userId; return userId;
}; };
export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
const match = href.match(MATRIX_TO_ROOM); const match = tryDecodeHref(href).match(MATRIX_TO_ROOM);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];
@ -68,7 +91,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
}; };
export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => {
const match = href.match(MATRIX_TO_ROOM_EVENT); const match = tryDecodeHref(href).match(MATRIX_TO_ROOM_EVENT);
if (!match) return undefined; if (!match) return undefined;
const roomIdOrAlias = match[1]; const roomIdOrAlias = match[1];