From d961dddfbc46ec49270cc2c3bc5ef0714cfc2cea Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 2 May 2026 00:44:52 +0300 Subject: [PATCH] feat(bots): land Phase 2 widget host/driver with retry UX and route-aware notifications --- capacitor.config.ts | 10 + package-lock.json | 8 +- package.json | 2 +- public/locales/en.json | 3 + public/locales/ru.json | 3 + src/app/features/bots/BotExperienceSlot.tsx | 79 +++++ src/app/features/bots/BotWidgetDriver.ts | 228 +++++++++++++ src/app/features/bots/BotWidgetEmbed.ts | 302 ++++++++++++++++++ src/app/features/bots/BotWidgetHost.css.ts | 28 ++ src/app/features/bots/BotWidgetHost.tsx | 18 ++ src/app/features/bots/catalog.ts | 51 +++ src/app/features/bots/room.ts | 21 +- src/app/features/bots/useBotRoom.ts | 5 +- src/app/features/bots/useBotWidgetEmbed.ts | 99 ++++++ src/app/features/room/Room.tsx | 10 +- src/app/hooks/router/useSelectedRoom.ts | 17 +- src/app/hooks/useClientConfig.ts | 4 + .../pages/client/bots/BotExperienceHost.tsx | 7 +- src/app/pages/client/bots/BotRoomProvider.tsx | 15 +- src/app/pages/client/direct/useDirectRooms.ts | 55 ++-- src/app/state/viewedRoom.ts | 13 + 21 files changed, 937 insertions(+), 41 deletions(-) create mode 100644 src/app/features/bots/BotExperienceSlot.tsx create mode 100644 src/app/features/bots/BotWidgetDriver.ts create mode 100644 src/app/features/bots/BotWidgetEmbed.ts create mode 100644 src/app/features/bots/BotWidgetHost.css.ts create mode 100644 src/app/features/bots/BotWidgetHost.tsx create mode 100644 src/app/features/bots/useBotWidgetEmbed.ts create mode 100644 src/app/state/viewedRoom.ts diff --git a/capacitor.config.ts b/capacitor.config.ts index 45e73227..8a677246 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -4,6 +4,16 @@ const config: CapacitorConfig = { appId: 'chat.vojo.app', appName: 'Vojo', webDir: 'dist', + // Bot widgets (docs/plans/bots_tab.md Phase 2): on Android, Capacitor's + // BridgeWebViewClient.shouldOverrideUrlLoading does NOT check + // request.isForMainFrame(), so any cross-origin iframe URL not in + // appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW + // and the iframe stays blank. Same-origin /widgets/... is safe (resolves + // to https://localhost under Capacitor). For HTTPS-hosted bot widgets, + // uncomment and edit the host list below BEFORE shipping a config.json + // that points at them: + // + // server: { allowNavigation: ['app.vojo.chat', '*.vojo.chat'] }, android: { // Keep default: resolveServiceWorkerRequests = true // SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+) diff --git a/package-lock.json b/package-lock.json index 3d57ef4f..13c30b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", - "matrix-widget-api": "1.13.0", + "matrix-widget-api": "1.17.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -10679,9 +10679,9 @@ } }, "node_modules/matrix-widget-api": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz", - "integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", + "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", diff --git a/package.json b/package.json index 73c1a7af..e75a0492 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", - "matrix-widget-api": "1.13.0", + "matrix-widget-api": "1.17.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", diff --git a/public/locales/en.json b/public/locales/en.json index f99fb1c2..1079008d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -937,6 +937,9 @@ "unsafe_title": "This robot chat is not private", "unsafe_description": "The robot is blocked because another active member is present in the chat.", "open_chat": "Open chat", + "show_chat": "Show chat", + "show_widget": "Show robot", + "retry_widget": "Retry robot", "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 35d197ae..02a88abb 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -941,6 +941,9 @@ "unsafe_title": "Чат с роботом не приватный", "unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.", "open_chat": "Открыть чат", + "show_chat": "Показать чат", + "show_widget": "Показать робота", + "retry_widget": "Повторить", "unknown_title": "Робот не найден", "unknown_description": "Этого робота нет в каталоге Vojo." } diff --git a/src/app/features/bots/BotExperienceSlot.tsx b/src/app/features/bots/BotExperienceSlot.tsx new file mode 100644 index 00000000..5e2b2d46 --- /dev/null +++ b/src/app/features/bots/BotExperienceSlot.tsx @@ -0,0 +1,79 @@ +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/BotWidgetDriver.ts b/src/app/features/bots/BotWidgetDriver.ts new file mode 100644 index 00000000..cb695be3 --- /dev/null +++ b/src/app/features/bots/BotWidgetDriver.ts @@ -0,0 +1,228 @@ +import { + EventDirection, + type Capability, + type IOpenIDUpdate, + type IRoomEvent, + type ISendEventDetails, + type SimpleObservable, + WidgetDriver, + WidgetEventCapability, + OpenIDRequestState, +} from 'matrix-widget-api'; +import { + EventTimeline, + EventType, + type IContent, + MatrixClient, + MatrixError, + MatrixEvent, + MsgType, + Room, +} from 'matrix-js-sdk'; +import { Membership } from '../../../types/matrix/room'; +import { isPortalRoomForOtherBridge } from './room'; +import type { BotPreset } from './catalog'; + +const BOT_WIDGET_TIMELINE_LIMIT = 100; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isAllowedMessageType = (msgtype: unknown): msgtype is 'm.text' | 'm.notice' => + msgtype === 'm.text' || msgtype === 'm.notice'; + +export const isSafeBotWidgetRoom = ( + mx: MatrixClient, + room: Room | null | undefined, + preset: BotPreset +): room is Room => { + if (!room) return false; + if (isPortalRoomForOtherBridge(room, preset.mxid)) return false; + + const myUserId = mx.getSafeUserId(); + if (room.getMember(myUserId)?.membership !== Membership.Join) return false; + if (room.getMember(preset.mxid)?.membership !== Membership.Join) return false; + + const activeMembers = room + .getMembers() + .filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite); + const allowedMembers = new Set([myUserId, preset.mxid]); + + return ( + activeMembers.length === allowedMembers.size && + activeMembers.every((m) => allowedMembers.has(m.userId)) + ); +}; + +export const sanitizeBotWidgetMessageEvent = (rawEvent: IRoomEvent): IRoomEvent | undefined => { + if (rawEvent.type !== EventType.RoomMessage) return undefined; + if (rawEvent.state_key !== undefined) return undefined; + if (!isObject(rawEvent.content)) return undefined; + if (typeof rawEvent.event_id !== 'string' || rawEvent.event_id.length === 0) return undefined; + if (typeof rawEvent.room_id !== 'string' || rawEvent.room_id.length === 0) return undefined; + if (typeof rawEvent.sender !== 'string') return undefined; + if (typeof rawEvent.origin_server_ts !== 'number') return undefined; + + const { msgtype, body } = rawEvent.content; + if (!isAllowedMessageType(msgtype) || typeof body !== 'string') return undefined; + + // Rebuild the outgoing event from primitives instead of spreading rawEvent — + // a future SDK upgrade could attach extra top-level keys (decryption hints, + // local-echo flags, custom org.matrix.* fields) and we never want those to + // ride out to a widget that only asked for {msgtype, body}. + return { + type: EventType.RoomMessage, + event_id: rawEvent.event_id, + room_id: rawEvent.room_id, + sender: rawEvent.sender, + origin_server_ts: rawEvent.origin_server_ts, + content: { msgtype, body }, + unsigned: {}, + }; +}; + +export const getBotWidgetCapabilities = (roomId: string): Set => + new Set([ + `org.matrix.msc2762.timeline:${roomId}`, + WidgetEventCapability.forRoomMessageEvent(EventDirection.Send, 'm.text').raw, + WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.text').raw, + WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.notice').raw, + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw, + ]); + +export class BotWidgetDriver extends WidgetDriver { + private readonly allowedCapabilities: Set; + + public constructor( + private readonly mx: MatrixClient, + private readonly roomId: string, + private readonly preset: BotPreset + ) { + super(); + this.allowedCapabilities = getBotWidgetCapabilities(roomId); + } + + private getSafeRoom(): Room | undefined { + const room = this.mx.getRoom(this.roomId); + return isSafeBotWidgetRoom(this.mx, room, this.preset) ? room : undefined; + } + + private isAllowedTargetRoom(targetRoomId?: string | null): boolean { + return !targetRoomId || targetRoomId === this.roomId; + } + + public async validateCapabilities(requested: Set): Promise> { + if (!this.getSafeRoom()) return new Set(); + + return new Set(Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap))); + } + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const room = this.getSafeRoom(); + if (!room || !this.isAllowedTargetRoom(targetRoomId)) { + throw new Error('Bot widget cannot send to this room'); + } + if (stateKey !== null) throw new Error('Bot widget cannot send state events'); + if (eventType !== EventType.RoomMessage) throw new Error('Bot widget cannot send this event'); + if (!isObject(content) || content.msgtype !== 'm.text' || typeof content.body !== 'string') { + throw new Error('Bot widget can only send m.text messages'); + } + + const result = await this.mx.sendEvent(room.roomId, EventType.RoomMessage, { + msgtype: MsgType.Text, + body: content.body, + }); + + return { roomId: room.roomId, eventId: result.event_id }; + } + + public async readRoomTimeline( + targetRoomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined + ): Promise { + const room = this.getSafeRoom(); + if (!room || !this.isAllowedTargetRoom(targetRoomId)) return []; + if (eventType === EventType.RoomMember) { + return this.readRoomState(targetRoomId, eventType, stateKey); + } + if (eventType !== EventType.RoomMessage) return []; + if (msgtype !== undefined && !isAllowedMessageType(msgtype)) return []; + + const safeLimit = + limit > 0 ? Math.min(limit, BOT_WIDGET_TIMELINE_LIMIT) : BOT_WIDGET_TIMELINE_LIMIT; + const events: MatrixEvent[] = []; + + const timelineEvents = room.getLiveTimeline().getEvents(); + for (let i = timelineEvents.length - 1; i >= 0; i -= 1) { + if (events.length >= safeLimit) break; + + const ev = timelineEvents[i]; + if (since !== undefined && ev.getId() === since) break; + if (!ev.isState() && ev.getType() === EventType.RoomMessage) { + const content = ev.getContent(); + if ( + isAllowedMessageType(content.msgtype) && + (msgtype === undefined || content.msgtype === msgtype) + ) { + events.push(ev); + } + } + } + + return events.flatMap((ev) => { + const sanitized = sanitizeBotWidgetMessageEvent(ev.getEffectiveEvent() as IRoomEvent); + return sanitized ? [sanitized] : []; + }); + } + + public async readRoomState( + targetRoomId: string, + eventType: string, + stateKey: string | undefined + ): Promise { + const room = this.getSafeRoom(); + if (!room || !this.isAllowedTargetRoom(targetRoomId)) return []; + if (eventType !== EventType.RoomMember) return []; + if ( + stateKey !== undefined && + stateKey !== this.mx.getSafeUserId() && + stateKey !== this.preset.mxid + ) { + return []; + } + + const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + if (!state) return []; + + const stateKeys = stateKey ? [stateKey] : [this.mx.getSafeUserId(), this.preset.mxid]; + return stateKeys.flatMap((key) => { + const ev = state.getStateEvents(EventType.RoomMember, key); + return ev ? [ev.getEffectiveEvent() as IRoomEvent] : []; + }); + } + + public getKnownRooms(): string[] { + return this.getSafeRoom() ? [this.roomId] : []; + } + + // eslint-disable-next-line class-methods-use-this + public askOpenID(observer: SimpleObservable): void { + observer.update({ state: OpenIDRequestState.Blocked }); + } + + // eslint-disable-next-line class-methods-use-this + public processError(error: unknown) { + return error instanceof MatrixError + ? { matrix_api_error: error.asWidgetApiErrorData() } + : undefined; + } +} diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts new file mode 100644 index 00000000..4c8ada47 --- /dev/null +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -0,0 +1,302 @@ +import { + MatrixClient, + MatrixEvent, + MatrixEventEvent, + EventType, + Room, + RoomEvent, + RoomStateEvent, + type RoomEventHandlerMap, +} from 'matrix-js-sdk'; +import { + ClientWidgetApi, + type IRoomEvent, + MatrixWidgetType, + Widget, + WidgetApiToWidgetAction, + type WidgetDriver, +} from 'matrix-widget-api'; +import { Theme } from '../../hooks/useTheme'; +import type { BotPreset } from './catalog'; +import { BotWidgetDriver, sanitizeBotWidgetMessageEvent } from './BotWidgetDriver'; + +export type BotWidgetEmbedOptions = { + mx: MatrixClient; + room: Room; + preset: BotPreset; + container: HTMLElement; + theme: Theme; + language: string; + onError: (error: Error) => void; +}; + +const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; + +const getBotWidgetUrl = ( + preset: BotPreset, + room: Room, + mx: MatrixClient, + theme: Theme, + language: string, + widgetId: string +): string => { + if (!preset.experience) throw new Error('Bot widget experience is not configured'); + + const url = new URL(preset.experience.url, window.location.origin); + url.searchParams.set('widgetId', widgetId); + url.searchParams.set('parentUrl', window.location.origin); + url.searchParams.set('roomId', room.roomId); + url.searchParams.set('userId', mx.getSafeUserId()); + url.searchParams.set('botId', preset.id); + url.searchParams.set('botMxid', preset.mxid); + url.searchParams.set('theme', theme.kind); + url.searchParams.set('clientLanguage', language); + url.searchParams.set('baseUrl', mx.baseUrl); + const deviceId = mx.getDeviceId(); + if (deviceId) url.searchParams.set('deviceId', deviceId); + + return url.toString(); +}; + +const createBotWidget = ( + preset: BotPreset, + room: Room, + mx: MatrixClient, + theme: Theme, + language: string +): Widget => { + const widgetId = getBotWidgetId(preset); + return new Widget({ + id: widgetId, + creatorUserId: mx.getSafeUserId(), + type: MatrixWidgetType.Custom, + name: preset.name, + url: getBotWidgetUrl(preset, room, mx, theme, language, widgetId), + waitForIframeLoad: true, + data: { + title: `${preset.name} Bot`, + }, + }); +}; + +// Build the iframe with all attributes EXCEPT `src`. The caller must assign +// `iframe.src` only AFTER `new ClientWidgetApi(widget, iframe, driver)` has +// attached its internal `load` listener — same-origin static widget HTML can +// finish loading before the listener is wired, and `beginCapabilities()` then +// never fires. This is the canonical Element-Web ordering. +const createBotIframe = (preset: BotPreset): HTMLIFrameElement => { + const iframe = document.createElement('iframe'); + + iframe.title = `${preset.name} Bot`; + // Sandbox aligns with docs/plans/bots_tab.md M8 minimum: scripts + forms + + // same-origin only. Add allow-popups / allow-popups-to-escape-sandbox / + // allow-downloads only when a specific widget requires them (e.g. an OAuth + // login flow), as a per-preset opt-in — not as a default. Element-Web's + // wider default exists because their widget set includes Element Call; + // Phase 2 bot widgets are text-protocol management surfaces. + iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin'); + iframe.allow = 'clipboard-write'; + iframe.referrerPolicy = 'no-referrer'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + return iframe; +}; + +export class BotWidgetEmbed { + public readonly api: ClientWidgetApi; + + public readonly iframe: HTMLIFrameElement; + + private disposed = false; + + // Gate live event/state feeding on the widget completing its capability + // handshake. Without this, an initial-sync burst from matrix-js-sdk can + // call api.feedEvent / api.feedStateUpdate while the postMessage transport + // has no widget on the other end yet — pending sends can timeout/reject and + // demote the widget into the chat fallback even though the widget is fine. + private isReady = false; + + private readonly disposables: Array<() => void> = []; + + // Dedup events that have already been forwarded to the widget. Encrypted DMs + // hit feedEvent twice in succession (once from RoomEvent.Timeline with the + // ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure + // chat rooms can also re-emit Timeline on store-rehydrate. Track by event_id + // so the widget never receives the same message twice. + private readonly fedEventIds = new Set(); + + private readonly onTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( + ev, + timelineRoom, + toStartOfTimeline, + _removed, + data + ) => { + if (timelineRoom?.roomId !== this.room.roomId) return; + // Back-paginated history must not replay into a fresh widget session — + // the widget asks for history explicitly via readRoomTimeline. + if (toStartOfTimeline) return; + if (data?.liveEvent === false) return; + this.mx.decryptEventIfNeeded(ev); + this.feedEvent(ev); + }; + + private readonly onEventDecrypted = (ev: MatrixEvent) => { + if (ev.getRoomId() === this.room.roomId) this.feedEvent(ev); + }; + + private readonly onStateUpdate = (ev: MatrixEvent) => { + if (ev.getRoomId() !== this.room.roomId) return; + this.feedStateUpdate(ev); + }; + + public constructor(private readonly options: BotWidgetEmbedOptions) { + const { mx, room, preset, container, theme, language } = options; + const widget = createBotWidget(preset, room, mx, theme, language); + const widgetUrl = widget.getCompleteUrl({ + currentUserId: mx.getSafeUserId(), + clientTheme: theme.kind, + clientLanguage: language, + widgetRoomId: room.roomId, + }); + + // Strict ordering — DO NOT reorder: + // 1. Build iframe with NO src. + // 2. Append iframe to DOM (creates contentWindow). + // 3. Construct ClientWidgetApi (attaches the internal `load` listener + // that triggers beginCapabilities()). + // 4. Only now assign iframe.src. + // If we set src in step 1 or 2, a same-origin static page can finish + // loading before step 3 wires the listener — beginCapabilities() never + // fires, no Capabilities request is sent to-widget, no `ready` event, + // and the widget hangs forever on a blank handshake. + const iframe = createBotIframe(preset); + container.append(iframe); + + const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset); + const api = new ClientWidgetApi(widget, iframe, driver); + + this.api = api; + this.iframe = iframe; + + api.setViewedRoomId(room.roomId); + + iframe.src = widgetUrl; + + const onReady = () => { + this.isReady = true; + // eslint-disable-next-line no-console + console.info('[BotWidget] handshake complete'); + }; + // Element-Web treats `error:preparing` as informational — it never tears + // down the widget. Vojo follows the same rule: log the upstream cause and + // leave the iframe mounted. The user can fall back to the chat manually + // via the «Show chat» toolbar; transient handshake hiccups (slow widget + // boot, dev hot-reload, mid-launch theme race) no longer demote a working + // session into chat. Real teardown still happens for: iframe network + // failure, room becomes unsafe, and host unmount. + const onPreparingError = (error: unknown) => { + // eslint-disable-next-line no-console + console.warn('[BotWidget] error:preparing (left mounted)', error); + }; + const onIframeError = (event: Event) => { + // eslint-disable-next-line no-console + console.error('[BotWidget] iframe error', event); + this.fail(new Error('Bot widget iframe failed to load')); + }; + + api.on('ready', onReady); + api.on('error:preparing', onPreparingError); + iframe.addEventListener('error', onIframeError); + room.on(RoomEvent.Timeline, this.onTimelineEvent); + mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + mx.on(RoomStateEvent.Events, this.onStateUpdate); + + this.disposables.push( + () => api.off('ready', onReady), + () => api.off('error:preparing', onPreparingError), + () => iframe.removeEventListener('error', onIframeError), + () => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent), + () => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted), + () => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate) + ); + } + + private get mx(): MatrixClient { + return this.options.mx; + } + + private get room(): Room { + return this.options.room; + } + + private fail(error: Error): void { + if (this.disposed) return; + // eslint-disable-next-line no-console + console.error('[BotWidget] fail:', error.message, error); + this.options.onError(error); + this.destroy(); + } + + // No per-event isSafeBotWidgetRoom re-check on the feed paths. Initial sync + // can transiently report "unsafe" before all members hydrate, and the per- + // event fail() that used to live here would tear the widget down on flap. + // Membership-driven teardown is the responsibility of `useBotRoom` upstream: + // when the room transitions out of `ready`, BotExperienceHost dispatches to + // a different state branch and unmounts the widget through React. The + // driver still applies the safety gate on every capability/sendEvent/read* + // call (BotWidgetDriver.getSafeRoom) so a hostile widget can't escalate. + private feedEvent(ev: MatrixEvent): void { + if (this.disposed || !this.isReady) return; + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + + const raw = ev.getEffectiveEvent() as Partial; + if (raw.room_id !== this.room.roomId) return; + if (typeof raw.event_id !== 'string' || raw.event_id.length === 0) return; + if (this.fedEventIds.has(raw.event_id)) return; + + const sanitized = sanitizeBotWidgetMessageEvent(raw as IRoomEvent); + if (!sanitized) return; + + this.fedEventIds.add(raw.event_id); + this.api.feedEvent(sanitized).catch((error) => { + // eslint-disable-next-line no-console + console.warn('[BotWidget] feedEvent rejected', error); + }); + } + + private feedStateUpdate(ev: MatrixEvent): void { + if (this.disposed || !this.isReady) return; + if (ev.getType() !== EventType.RoomMember) return; + const stateKey = ev.getStateKey(); + if (stateKey !== this.mx.getSafeUserId() && stateKey !== this.options.preset.mxid) return; + + this.api.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch((error) => { + // eslint-disable-next-line no-console + console.warn('[BotWidget] feedStateUpdate rejected', error); + }); + } + + // Push a runtime theme change. No-op until the widget completes the + // capability handshake — the initial theme is already in the URL params, so + // sending it pre-handshake would just queue a postMessage request that times + // out after 10 s in the transport (matrix-widget-api default). + public setTheme(theme: Theme): Promise { + if (this.disposed || !this.isReady) return Promise.resolve(); + return this.api.transport + .send(WidgetApiToWidgetAction.ThemeChange, { name: theme.kind }) + .then(() => undefined); + } + + public destroy(): void { + if (this.disposed) return; + + this.disposed = true; + this.disposables.forEach((dispose) => dispose()); + this.api.stop(); + this.iframe.remove(); + this.fedEventIds.clear(); + } +} diff --git a/src/app/features/bots/BotWidgetHost.css.ts b/src/app/features/bots/BotWidgetHost.css.ts new file mode 100644 index 00000000..b1a8a61e --- /dev/null +++ b/src/app/features/bots/BotWidgetHost.css.ts @@ -0,0 +1,28 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +export const Host = style([ + DefaultReset, + { + position: 'relative', + width: '100%', + height: '100%', + backgroundColor: color.Background.Container, + }, +]); + +export const FrameMount = style({ + width: '100%', + height: '100%', +}); + +export const Toolbar = style([ + DefaultReset, + { + position: 'absolute', + top: config.space.S300, + right: config.space.S300, + zIndex: 1, + maxWidth: `calc(100% - ${toRem(24)})`, + }, +]); diff --git a/src/app/features/bots/BotWidgetHost.tsx b/src/app/features/bots/BotWidgetHost.tsx new file mode 100644 index 00000000..acfa0c2b --- /dev/null +++ b/src/app/features/bots/BotWidgetHost.tsx @@ -0,0 +1,18 @@ +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/catalog.ts b/src/app/features/bots/catalog.ts index c11f2850..8fda997b 100644 --- a/src/app/features/bots/catalog.ts +++ b/src/app/features/bots/catalog.ts @@ -2,12 +2,18 @@ import { useMemo } from 'react'; import { useClientConfig } from '../../hooks/useClientConfig'; import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig'; +export type BotExperience = { + type: 'matrix-widget'; + url: string; +}; + export type BotPreset = { /** Stable URL slug — `/bots/`. Never reuse across bots. */ id: string; /** Bot user mxid. The DM with this user IS the bot's control room. */ mxid: string; name: string; + experience?: BotExperience; }; const BOT_ID_RE = /^[A-Za-z0-9_-]+$/; @@ -21,6 +27,50 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset => typeof preset.name === 'string' && preset.name.trim().length > 0; +const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => { + const type = experience?.type; + const url = experience?.url?.trim(); + if (type !== 'matrix-widget' || !url) return undefined; + if (url.startsWith('//')) 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 + // not escape its own segment. Same-origin widgets share the parent's + // localStorage/cookies under `allow-same-origin`, so /widgets/ is the only + // path the operator config is allowed to mount. + // + // Path MUST resolve to a concrete file: the last segment must contain a + // dot (e.g. /widgets/foo/index.html, /widgets/foo.js). Bare directory + // paths or extensionless paths (/widgets/foo, /widgets/foo/) get caught + // by SPA fallback in Vite dev and any history-fallback static server, + // which would then serve the parent app's index.html into the iframe — + // recursive Vojo-in-Vojo sharing localStorage and the matrix client, + // and the two copies infinite-loop on sync events. The dot-in-last- + // segment heuristic keeps the validator simple while catching the + // realistic accident class. + try { + const resolved = new URL(url, 'https://vojo.invalid'); + if (resolved.username || resolved.password) return undefined; + 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}` }; + } catch { + return undefined; + } + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return undefined; + if (parsed.username || parsed.password) return undefined; + return { type, url: parsed.toString() }; + } catch { + return undefined; + } +}; + export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => { const seenIds = new Set(); const seenMxids = new Set(); @@ -36,6 +86,7 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => { id: preset.id, mxid: preset.mxid, name: preset.name.trim(), + experience: normalizeBotExperience(preset.experience), }); }); diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts index 650f55c4..efeba6c4 100644 --- a/src/app/features/bots/room.ts +++ b/src/app/features/bots/room.ts @@ -1,6 +1,6 @@ import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { Membership, StateEvent } from '../../../types/matrix/room'; -import { isBridgedRoom, isRoom } from '../../utils/room'; +import { getStateEvents, isRoom } from '../../utils/room'; import type { BotPreset } from './catalog'; const ACTIVE_MEMBERSHIPS = new Set([Membership.Join, Membership.Invite]); @@ -10,8 +10,25 @@ export const isBridgeStateEvent = (ev: MatrixEvent): boolean => { return type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable; }; +// A room is a "portal" (Telegram/Signal/etc. mirror chat) if it carries an +// m.bridge / uk.half-shot.bridge state event whose state_key is NOT our +// control bot. Bridge BOTS write m.bridge into their OWN control DMs too, +// keyed by the bot mxid — those are management rooms, not portals, and the +// Robots tab must own them. The plain `isBridgedRoom` helper in utils/room +// can't make this distinction (it has no concept of "our bot"); it stays +// correct for DmCallButton's call-button suppression but is too coarse for +// the bot ownership question. Single source of truth — both useBotRoom's +// classifyRoom and BotWidgetDriver.isSafeBotWidgetRoom delegate here. +export const isPortalRoomForOtherBridge = (room: Room, controlBotMxid: string): boolean => { + const events = [ + ...getStateEvents(room, StateEvent.RoomBridge), + ...getStateEvents(room, StateEvent.RoomBridgeUnstable), + ]; + return events.some((ev) => ev.getStateKey() !== controlBotMxid); +}; + export const isBotControlRoom = (mx: MatrixClient, room: Room, preset: BotPreset): boolean => { - if (!isRoom(room) || isBridgedRoom(room)) return false; + if (!isRoom(room) || isPortalRoomForOtherBridge(room, preset.mxid)) return false; const myUserId = mx.getUserId(); if (!myUserId) return false; diff --git a/src/app/features/bots/useBotRoom.ts b/src/app/features/bots/useBotRoom.ts index 0c5595a5..d12a3cf9 100644 --- a/src/app/features/bots/useBotRoom.ts +++ b/src/app/features/bots/useBotRoom.ts @@ -11,8 +11,7 @@ import { import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Membership } from '../../../types/matrix/room'; import { removeRoomIdFromMDirect } from '../../utils/matrix'; -import { isBridgedRoom } from '../../utils/room'; -import { isBotControlRoom, isBridgeStateEvent } from './room'; +import { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } from './room'; import type { BotPreset } from './catalog'; // Discriminated union over the lifecycle of a bot DM. Mount-eligible only at @@ -38,7 +37,7 @@ const classifyRoom = ( room: Room, preset: BotPreset ): BotRoomState | undefined => { - if (isBridgedRoom(room)) return undefined; + if (isPortalRoomForOtherBridge(room, preset.mxid)) return undefined; const my = room.getMyMembership(); const bot = room.getMember(preset.mxid)?.membership; diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts new file mode 100644 index 00000000..7da29655 --- /dev/null +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -0,0 +1,99 @@ +import { RefObject, useEffect, useRef } from 'react'; +import { Room } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { Theme, useTheme } from '../../hooks/useTheme'; +import type { BotPreset } from './catalog'; +import { BotWidgetEmbed } from './BotWidgetEmbed'; + +type UseBotWidgetEmbedOptions = { + containerRef: RefObject; + preset: BotPreset; + room: Room; + onError: () => void; +}; + +// Encapsulates the imperative BotWidgetEmbed lifecycle so the host React +// component stays small. Mirrors useCallEmbed's split: the hook owns the +// effect, the component owns the DOM ref + visual chrome. +// +// Returns a ref to the live embed instance; the host can use it to push +// runtime updates (e.g. theme) without forcing an iframe remount. +export const useBotWidgetEmbed = ({ + containerRef, + preset, + room, + onError, +}: UseBotWidgetEmbedOptions): RefObject => { + const { i18n } = useTranslation(); + const mx = useMatrixClient(); + const theme = useTheme(); + const embedRef = useRef(); + + // Capture mount-time values in refs so the lifecycle effect doesn't re-run + // when locale/theme change — those are propagated separately via setTheme. + const themeRef = useRef(theme); + themeRef.current = theme; + const languageRef = useRef(i18n.language); + languageRef.current = i18n.language; + + // Depend on primitive identity for the embed lifecycle — using `preset` + // directly would remount the iframe (and re-handshake with the widget) + // every time `useBotPresets` returns a fresh memo, e.g. after a runtime + // config refresh. + const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset; + const experienceUrl = experience?.url; + const experienceType = experience?.type; + const { roomId } = room; + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + let embed: BotWidgetEmbed | undefined; + try { + embed = new BotWidgetEmbed({ + mx, + room, + preset, + container, + theme: themeRef.current, + language: languageRef.current, + onError, + }); + embedRef.current = embed; + } catch (error) { + // eslint-disable-next-line no-console + console.error('[BotWidget] constructor threw', error); + onError(); + } + + return () => { + embed?.destroy(); + if (embedRef.current === embed) { + embedRef.current = undefined; + } + }; + // theme/language read above are intentionally not in deps — they are + // captured via refs and propagated by the separate effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + mx, + containerRef, + roomId, + presetId, + presetMxid, + presetName, + experienceUrl, + experienceType, + onError, + ]); + + // Runtime theme propagation — embed.setTheme is a no-op until the widget + // handshake completes, so calling it on every theme tick is safe. + useEffect(() => { + embedRef.current?.setTheme(theme).catch(() => undefined); + }, [theme]); + + return embedRef; +}; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 6455ac0c..5a453cda 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -19,7 +19,11 @@ import { RoomViewHeader } from './RoomViewHeader'; import { callChatAtom } from '../../state/callEmbed'; import { CallChatView } from './CallChatView'; -export function Room() { +type RoomProps = { + renderRoomView?: (props: { eventId?: string }) => React.ReactNode; +}; + +export function Room({ renderRoomView }: RoomProps) { const { eventId } = useParams(); const room = useRoom(); const mx = useMatrixClient(); @@ -65,9 +69,7 @@ export function Room() { {!callView && ( - - - + {renderRoomView?.({ eventId }) ?? } )} diff --git a/src/app/hooks/router/useSelectedRoom.ts b/src/app/hooks/router/useSelectedRoom.ts index 6d0ee4f1..85b41f2f 100644 --- a/src/app/hooks/router/useSelectedRoom.ts +++ b/src/app/hooks/router/useSelectedRoom.ts @@ -1,15 +1,22 @@ import { useParams } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; import { useMatrixClient } from '../useMatrixClient'; +import { viewedRoomIdAtom } from '../../state/viewedRoom'; +// Returns the id of the room the user is currently looking at. Resolution +// order: (1) URL param `roomIdOrAlias` for the canonical /direct, /home, +// /:spaceId/* routes; (2) `viewedRoomIdAtom` for routes whose URL key isn't +// the room id itself (e.g. /bots/:botId, where BotExperienceHost writes the +// derived control-room id into the atom on mount). export const useSelectedRoom = (): string | undefined => { const mx = useMatrixClient(); + const viewedRoomId = useAtomValue(viewedRoomIdAtom); const { roomIdOrAlias } = useParams(); - const roomId = - roomIdOrAlias && isRoomAlias(roomIdOrAlias) - ? getCanonicalAliasRoomId(mx, roomIdOrAlias) - : roomIdOrAlias; + if (roomIdOrAlias) { + return isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias; + } - return roomId; + return viewedRoomId; }; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 3c3371d6..f7b8dc50 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -16,6 +16,10 @@ export type BotConfig = { id?: string; mxid?: string; name?: string; + experience?: { + type?: string; + url?: string; + }; }; export type ClientConfig = { diff --git a/src/app/pages/client/bots/BotExperienceHost.tsx b/src/app/pages/client/bots/BotExperienceHost.tsx index 42dfebd6..777c9c34 100644 --- a/src/app/pages/client/bots/BotExperienceHost.tsx +++ b/src/app/pages/client/bots/BotExperienceHost.tsx @@ -3,6 +3,7 @@ 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 { useBotRoom } from '../../../features/bots/useBotRoom'; import { Room } from '../../../features/room'; import { BotInvitePending } from './BotInvitePending'; @@ -53,7 +54,11 @@ export function BotExperienceHost() { return ( - + ( + + )} + /> ); } diff --git a/src/app/pages/client/bots/BotRoomProvider.tsx b/src/app/pages/client/bots/BotRoomProvider.tsx index a0635be2..64d38a5c 100644 --- a/src/app/pages/client/bots/BotRoomProvider.tsx +++ b/src/app/pages/client/bots/BotRoomProvider.tsx @@ -1,7 +1,9 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import { Room as MatrixRoom } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom'; import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom'; +import { viewedRoomIdAtom } from '../../../state/viewedRoom'; type BotRoomProviderProps = { room: MatrixRoom; @@ -10,6 +12,17 @@ type BotRoomProviderProps = { export function BotRoomProvider({ room, children }: BotRoomProviderProps) { const isOneOnOne = useIsOneOnOneRoom(room); + const setViewedRoomId = useSetAtom(viewedRoomIdAtom); + + // /bots/:botId routes don't carry the control room id in the URL — write + // the resolved id into a global atom so consumers like ClientNonUIFeatures + // (notification suppression) and CallStatusRenderer can recognise that + // this room is on screen. Cleared on unmount to avoid leaking the bot DM + // id to other routes. + useEffect(() => { + setViewedRoomId(room.roomId); + return () => setViewedRoomId(undefined); + }, [room.roomId, setViewedRoomId]); return ( diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts index 69481bd2..52b87adb 100644 --- a/src/app/pages/client/direct/useDirectRooms.ts +++ b/src/app/pages/client/direct/useDirectRooms.ts @@ -42,7 +42,7 @@ export const useDirectRooms = (): string[] => { () => new Set([...orphanRooms, ...directs]), [orphanRooms, directs] ); - const [, setRoomStateTick] = useState(0); + const [roomStateTick, setRoomStateTick] = useState(0); useEffect(() => { const bumpIfCandidate = (roomId: string | undefined) => { @@ -66,25 +66,40 @@ export const useDirectRooms = (): string[] => { }; }, [mx, directCandidates]); - const seen = new Set(); - const out: string[] = []; - const isVisibleDirect = (id: string): boolean => { - const room = mx.getRoom(id); - return !room || !isCatalogBotControlRoom(mx, room, bots); - }; + // The output MUST be a stable reference across renders that don't change + // input identity — `useRoomsUnread` (state/hooks/unread.ts:37) feeds this + // array into a `useCallback([rooms])` whose result is then handed to + // `selectAtom(...)` on every render. If `rooms` identity flips each render, + // `selectAtom` produces a fresh atom subscription which triggers another + // render — that's the React-detected "Maximum update depth exceeded" loop + // observed in DirectTab. `roomStateTick` is in deps so member/bridge state + // updates still recompute the visible set. + return useMemo(() => { + const seen = new Set(); + const out: string[] = []; + const isVisibleDirect = (id: string): boolean => { + const room = mx.getRoom(id); + return !room || !isCatalogBotControlRoom(mx, room, bots); + }; - orphanRooms.forEach((id) => { - if (!seen.has(id) && isVisibleDirect(id)) { - seen.add(id); - out.push(id); - } - }); - directs.forEach((id) => { - if (!seen.has(id) && isVisibleDirect(id)) { - seen.add(id); - out.push(id); - } - }); + orphanRooms.forEach((id) => { + if (!seen.has(id) && isVisibleDirect(id)) { + seen.add(id); + out.push(id); + } + }); + directs.forEach((id) => { + if (!seen.has(id) && isVisibleDirect(id)) { + seen.add(id); + out.push(id); + } + }); - return out; + return out; + // roomStateTick is intentional: the listener bumps it on member/bridge + // state events to force re-evaluation, even though the value isn't read + // inside the body. Removing it would make the visible-direct set go + // stale until one of the input arrays' identities changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mx, orphanRooms, directs, bots, roomStateTick]); }; diff --git a/src/app/state/viewedRoom.ts b/src/app/state/viewedRoom.ts new file mode 100644 index 00000000..dfb43f4f --- /dev/null +++ b/src/app/state/viewedRoom.ts @@ -0,0 +1,13 @@ +import { atom } from 'jotai'; + +// Cross-route source of truth for "the room the user is currently looking at", +// independent of URL `roomIdOrAlias` params. Routes that don't carry the room +// id in the URL (e.g. /bots/:botId — the route key is a preset id, the room +// id is derived) write here so that consumers like ClientNonUIFeatures' +// notification suppression know which room is on screen. +// +// Existing /direct, /home, /:spaceId/* routes do NOT need to write here — +// `useSelectedRoom()` resolves their roomId from URL params already, and the +// atom is consulted only as a fallback. Keep writers narrow to avoid having +// two sources of truth racing for routes the URL already covers. +export const viewedRoomIdAtom = atom(undefined);