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>
);
// 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.
const URL_RE = /https?:\/\/[^\s)]+/g;
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
`script[src^="https://js.hcaptcha.com/1/api.js"]`
) 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 settled = false;
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-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? (
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
</div>
<div class="auth-card-row">
<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
// --------------------------------------------------------------------------
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
// hydrate too; the live path treats it identically.
append({ kind: 'diag', text: t('diag.captcha-issued') });
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') {
// m.text / m.notice — body is safe to replay verbatim,
// 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') });
} else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current;
if (
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
}
} else if (event.kind === 'captcha_challenge') {
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
// transcript DOM (where screenshots / accessibility tools could
// leak them). Diag-only display.
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') {
const body = ev.content.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
// and trigger reconnect from there).
const handle =
state.kind === 'connected_dead' || state.kind === 'connected'
? state.handle
: undefined;
state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
dispatch({ kind: 'request_reconnect', handle });
try {
await sendBare('reconnect');
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
}
/>
<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} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</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_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
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// 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' };
// 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);
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'
? event.redacts
: isObject(event.content) && typeof event.content.redacts === 'string'
? event.content.redacts
: undefined;
? event.content.redacts
: undefined;
if (!target) return { kind: 'unknown' };
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
// `ping` afterwards to pick up the discordId.
[
'Successfully logged in as @example',
{ kind: 'login_success', handle: 'example' },
],
[
'Successfully logged in as @user.name',
{ kind: 'login_success', handle: 'user.name' },
],
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
// 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
// unpatched upstream v0.7.6.
[
@ -387,10 +409,7 @@ function runSanityChecks(): void {
// Logout.
['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.
['Successfully disconnected', { kind: 'disconnect_ok' }],
@ -521,7 +540,9 @@ function runSanityChecks(): void {
// eslint-disable-next-line no-console
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
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_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 --------------------------------------------------
// Generic «I don't know that command» — should not happen since we only
// 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.',
'auth-error.captcha-send-failed':
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
'auth-error.captcha-expired':
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.login-failed': 'Sign-in failed: {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.connect-after-login-failed':
'Signed in, but could not connect to Discord: {reason}',
'auth-error.connect-after-login-failed': '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.unknown-command':
'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-yes': 'Sign out',
'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.ready': 'Ready to send commands.',
'diag.checking-status': 'Checking connection status…',

View file

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

View file

@ -104,8 +104,13 @@ export type LoginState =
| { kind: 'reconnecting'; handle?: string }
// Live session — ping or login_success confirmed. Discord legacy bridge
// doesn't have a per-account loginId concept (single Discord account
// per Matrix user), so logout doesn't need an id.
| { kind: 'connected'; handle: string; discordId?: string }
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
// 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:
// green-ish but with a Reconnect recovery action exposed. The reducer
// 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
// often valid for a couple of minutes — fresh enough to reuse). Other
// transient states (logging_out, reconnecting) deliberately don't survive.
export type HydrateRestoredState =
| PendingFormState
| CaptchaSolveState
| { kind: 'qr_verifying' };
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
// Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions optimistically advance state —
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
const isCaptchaAcceptingState = (
s: LoginState
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
s.kind === 'awaiting_qr_scan' ||
s.kind === 'qr_verifying' ||
s.kind === 'awaiting_captcha_solve';
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'hydrate') {
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
case 'logged_in':
// Authoritative source — accept from any state. Used by both the
// 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 {
kind: 'connected',
handle: event.handle,
discordId: event.discordId,
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
};
case 'connection_dead':
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
// green with an empty handle, which the UI's
// `state.handle ? connected-as : connected` ternary tolerates.
// 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') {
return { kind: 'connected', handle: state.handle ?? '' };
}
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':
if (state.kind !== 'reconnecting') return state;
// Roll back to connected_dead carrying the previous handle. The
@ -565,10 +584,7 @@ type HydrateAccumulator = {
terminated: boolean;
};
const stepHydrate = (
prevAcc: HydrateAccumulator,
input: HydrateInput
): HydrateAccumulator => {
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
const { ev, ts } = input;
// After a terminal event we normally stop — except if a fresh
@ -693,9 +709,12 @@ const stepHydrate = (
case 'already_logged_in':
case 'unknown':
case 'space_ready':
// Soft no-op for hydrate. already_logged_in is a live-flow warning
// 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;
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).
// Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -18,6 +18,7 @@ import {
} from 'matrix-widget-api';
import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog';
import {
BotWidgetDriver,
@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = {
language: string;
onError: (error: Error) => 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}`;
@ -214,22 +223,30 @@ export class BotWidgetEmbed {
this.feedStateUpdate(ev);
};
// Side-channel postMessage handler for the widget's `openExternalUrl`
// call. Distinct from matrix-widget-api's `fromWidget` channel
// Side-channel postMessage handler for the widget's Vojo-extension
// actions. Distinct from matrix-widget-api's `fromWidget` channel
// (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path.
//
// Why this exists: the host's global `setupExternalLinkHandler`
// (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.
// Two actions today:
//
// 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
// check, a compromised widget bundle could `window.location.href
// = '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
// sibling frame on the same origin in a future deployment —
// 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
// http; rejecting http closes a cleartext-redirect vector via
// Capacitor `Browser.open` on Android.
// 4. javascript:, data:, file:, etc. are implicitly rejected by (3).
//
// Per-action URL validation (NOT shared, but each branch enforces):
// * `open-external-url` — requires `https:` protocol, rejecting plain
// http, javascript:, data:, file:, etc. We tightened from http+https
// 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) => {
if (ev.origin !== this.widgetOrigin) return;
if (ev.source !== this.iframe.contentWindow) return;
@ -255,18 +278,38 @@ export class BotWidgetEmbed {
| undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return;
if (msg.action !== 'open-external-url') return;
const url = msg.data?.url;
if (typeof url !== 'string') return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
if (msg.action === 'open-external-url') {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return;
} catch {
return;
}
openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
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) {

View file

@ -1,8 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Room, SyncState } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient';
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 * as css from './BotWidgetMount.css';
@ -34,15 +42,46 @@ type BotWidgetMountProps = {
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
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
// SyncIndicator when the connection is unhealthy. Without this, on a
// dropped network the user would see TWO sweeping bars at once — the
// bot bar at top stuck in «still loading» plus the SyncIndicator at
// bottom in transient/error state. The bottom bar is the canonical
// connection-state surface; the top one defers.
const mx = useMatrixClient();
// connection-state surface; the top one defers. Reuses `mx` from the
// navigate-callback block above — single hook call per render.
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
useSyncState(
mx,
@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
// SyncIndicator can take over without two bars overlapping.
// Reduced-motion: animation is off (no iterations ever land), so
// parking a static stripe for ~2s isn't graceful, just stuck.
if (
hideReason === 'sync' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setVisible(false);
setPendingHide(false);
return undefined;

View file

@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Theme, useTheme } from '../../hooks/useTheme';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog';
import { BotWidgetEmbed } from './BotWidgetEmbed';
@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = {
preset: BotPreset;
room: Room;
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 = {
@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({
preset,
room,
onError,
onOpenMatrixToRoom,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({
themeRef.current = theme;
const languageRef = useRef<string>(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`
// directly would remount the iframe (and re-handshake with the widget)
@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({
language: languageRef.current,
onError,
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;
} catch (error) {

View file

@ -41,20 +41,43 @@ export type MatrixToRoomEvent = MatrixToRoom & {
const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
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_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
const MATRIX_TO_ROOM_EVENT =
/^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
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;
const userId = match[1];
return userId;
};
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;
const roomIdOrAlias = match[1];
@ -68,7 +91,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | 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;
const roomIdOrAlias = match[1];