feat(bots): land Phase 2 widget host/driver with retry UX and route-aware notifications

This commit is contained in:
heaven 2026-05-02 00:44:52 +03:00
parent 83e246da1f
commit d961dddfbc
21 changed files with 937 additions and 41 deletions

View file

@ -4,6 +4,16 @@ const config: CapacitorConfig = {
appId: 'chat.vojo.app', appId: 'chat.vojo.app',
appName: 'Vojo', appName: 'Vojo',
webDir: 'dist', 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: { android: {
// Keep default: resolveServiceWorkerRequests = true // Keep default: resolveServiceWorkerRequests = true
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+) // SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)

8
package-lock.json generated
View file

@ -53,7 +53,7 @@
"linkify-react": "4.3.2", "linkify-react": "4.3.2",
"linkifyjs": "4.3.2", "linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.0", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
@ -10679,9 +10679,9 @@
} }
}, },
"node_modules/matrix-widget-api": { "node_modules/matrix-widget-api": {
"version": "1.13.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz", "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==", "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/events": "^3.0.0", "@types/events": "^3.0.0",

View file

@ -85,7 +85,7 @@
"linkify-react": "4.3.2", "linkify-react": "4.3.2",
"linkifyjs": "4.3.2", "linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.0", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",

View file

@ -937,6 +937,9 @@
"unsafe_title": "This robot chat is not private", "unsafe_title": "This robot chat is not private",
"unsafe_description": "The robot is blocked because another active member is present in the chat.", "unsafe_description": "The robot is blocked because another active member is present in the chat.",
"open_chat": "Open chat", "open_chat": "Open chat",
"show_chat": "Show chat",
"show_widget": "Show robot",
"retry_widget": "Retry robot",
"unknown_title": "Robot not found", "unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog." "unknown_description": "This robot is not in the Vojo catalog."
} }

View file

@ -941,6 +941,9 @@
"unsafe_title": "Чат с роботом не приватный", "unsafe_title": "Чат с роботом не приватный",
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.", "unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
"open_chat": "Открыть чат", "open_chat": "Открыть чат",
"show_chat": "Показать чат",
"show_widget": "Показать робота",
"retry_widget": "Повторить",
"unknown_title": "Робот не найден", "unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo." "unknown_description": "Этого робота нет в каталоге Vojo."
} }

View file

@ -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 <RoomView eventId={eventId} />;
}
if (showRawChat) {
return (
<Box className={css.Host}>
<Box className={css.FrameMount}>
<RoomView eventId={eventId} />
</Box>
<Box className={css.Toolbar}>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowWidget}>
<Text size="B300">{failed ? t('Bots.retry_widget') : t('Bots.show_widget')}</Text>
</Button>
</Box>
</Box>
);
}
return (
<Box className={css.Host}>
<BotWidgetHost key={retryCount} preset={preset} room={room} onError={handleWidgetError} />
<Box className={css.Toolbar}>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowChat}>
<Text size="B300">{t('Bots.show_chat')}</Text>
</Button>
</Box>
</Box>
);
}

View file

@ -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<string, unknown> =>
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<Capability> =>
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<Capability>;
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<Capability>): Promise<Set<Capability>> {
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<ISendEventDetails> {
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<IRoomEvent[]> {
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<IRoomEvent[]> {
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<IOpenIDUpdate>): 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;
}
}

View file

@ -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<string>();
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<IRoomEvent>;
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<void> {
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();
}
}

View file

@ -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)})`,
},
]);

View file

@ -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<HTMLDivElement>(null);
useBotWidgetEmbed({ containerRef, preset, room, onError });
return <div ref={containerRef} className={css.FrameMount} />;
}

View file

@ -2,12 +2,18 @@ import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig'; import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig'; import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
export type BotExperience = {
type: 'matrix-widget';
url: string;
};
export type BotPreset = { export type BotPreset = {
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */ /** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
id: string; id: string;
/** Bot user mxid. The DM with this user IS the bot's control room. */ /** Bot user mxid. The DM with this user IS the bot's control room. */
mxid: string; mxid: string;
name: string; name: string;
experience?: BotExperience;
}; };
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/; const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
@ -21,6 +27,50 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset.name === 'string' && typeof preset.name === 'string' &&
preset.name.trim().length > 0; 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[] => { export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
const seenIds = new Set<string>(); const seenIds = new Set<string>();
const seenMxids = new Set<string>(); const seenMxids = new Set<string>();
@ -36,6 +86,7 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
id: preset.id, id: preset.id,
mxid: preset.mxid, mxid: preset.mxid,
name: preset.name.trim(), name: preset.name.trim(),
experience: normalizeBotExperience(preset.experience),
}); });
}); });

View file

@ -1,6 +1,6 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { Membership, StateEvent } from '../../../types/matrix/room'; import { Membership, StateEvent } from '../../../types/matrix/room';
import { isBridgedRoom, isRoom } from '../../utils/room'; import { getStateEvents, isRoom } from '../../utils/room';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
const ACTIVE_MEMBERSHIPS = new Set<string>([Membership.Join, Membership.Invite]); const ACTIVE_MEMBERSHIPS = new Set<string>([Membership.Join, Membership.Invite]);
@ -10,8 +10,25 @@ export const isBridgeStateEvent = (ev: MatrixEvent): boolean => {
return type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable; 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 => { 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(); const myUserId = mx.getUserId();
if (!myUserId) return false; if (!myUserId) return false;

View file

@ -11,8 +11,7 @@ import {
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Membership } from '../../../types/matrix/room'; import { Membership } from '../../../types/matrix/room';
import { removeRoomIdFromMDirect } from '../../utils/matrix'; import { removeRoomIdFromMDirect } from '../../utils/matrix';
import { isBridgedRoom } from '../../utils/room'; import { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } from './room';
import { isBotControlRoom, isBridgeStateEvent } from './room';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
// Discriminated union over the lifecycle of a bot DM. Mount-eligible only at // Discriminated union over the lifecycle of a bot DM. Mount-eligible only at
@ -38,7 +37,7 @@ const classifyRoom = (
room: Room, room: Room,
preset: BotPreset preset: BotPreset
): BotRoomState | undefined => { ): BotRoomState | undefined => {
if (isBridgedRoom(room)) return undefined; if (isPortalRoomForOtherBridge(room, preset.mxid)) return undefined;
const my = room.getMyMembership(); const my = room.getMyMembership();
const bot = room.getMember(preset.mxid)?.membership; const bot = room.getMember(preset.mxid)?.membership;

View file

@ -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<HTMLElement>;
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<BotWidgetEmbed | undefined> => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
const theme = useTheme();
const embedRef = useRef<BotWidgetEmbed>();
// 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>(theme);
themeRef.current = theme;
const languageRef = useRef<string>(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;
};

View file

@ -19,7 +19,11 @@ import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView'; import { CallChatView } from './CallChatView';
export function Room() { type RoomProps = {
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
};
export function Room({ renderRoomView }: RoomProps) {
const { eventId } = useParams(); const { eventId } = useParams();
const room = useRoom(); const room = useRoom();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -65,9 +69,7 @@ export function Room() {
{!callView && ( {!callView && (
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<RoomViewHeader /> <RoomViewHeader />
<Box grow="Yes"> <Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
<RoomView eventId={eventId} />
</Box>
</Box> </Box>
)} )}

View file

@ -1,15 +1,22 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
import { useMatrixClient } from '../useMatrixClient'; 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 => { export const useSelectedRoom = (): string | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const viewedRoomId = useAtomValue(viewedRoomIdAtom);
const { roomIdOrAlias } = useParams(); const { roomIdOrAlias } = useParams();
const roomId = if (roomIdOrAlias) {
roomIdOrAlias && isRoomAlias(roomIdOrAlias) return isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias;
? getCanonicalAliasRoomId(mx, roomIdOrAlias) }
: roomIdOrAlias;
return roomId; return viewedRoomId;
}; };

View file

@ -16,6 +16,10 @@ export type BotConfig = {
id?: string; id?: string;
mxid?: string; mxid?: string;
name?: string; name?: string;
experience?: {
type?: string;
url?: string;
};
}; };
export type ClientConfig = { export type ClientConfig = {

View file

@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import { Icon, Icons } from 'folds'; import { Icon, Icons } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog'; import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot';
import { useBotRoom } from '../../../features/bots/useBotRoom'; import { useBotRoom } from '../../../features/bots/useBotRoom';
import { Room } from '../../../features/room'; import { Room } from '../../../features/room';
import { BotInvitePending } from './BotInvitePending'; import { BotInvitePending } from './BotInvitePending';
@ -53,7 +54,11 @@ export function BotExperienceHost() {
return ( return (
<BotRoomProvider room={room}> <BotRoomProvider room={room}>
<Room /> <Room
renderRoomView={({ eventId }) => (
<BotExperienceSlot preset={preset} room={room} eventId={eventId} />
)}
/>
</BotRoomProvider> </BotRoomProvider>
); );
} }

View file

@ -1,7 +1,9 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { Room as MatrixRoom } from 'matrix-js-sdk'; import { Room as MatrixRoom } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom'; import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom'; import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
import { viewedRoomIdAtom } from '../../../state/viewedRoom';
type BotRoomProviderProps = { type BotRoomProviderProps = {
room: MatrixRoom; room: MatrixRoom;
@ -10,6 +12,17 @@ type BotRoomProviderProps = {
export function BotRoomProvider({ room, children }: BotRoomProviderProps) { export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
const isOneOnOne = useIsOneOnOneRoom(room); 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 ( return (
<RoomProvider key={room.roomId} value={room}> <RoomProvider key={room.roomId} value={room}>

View file

@ -42,7 +42,7 @@ export const useDirectRooms = (): string[] => {
() => new Set([...orphanRooms, ...directs]), () => new Set([...orphanRooms, ...directs]),
[orphanRooms, directs] [orphanRooms, directs]
); );
const [, setRoomStateTick] = useState(0); const [roomStateTick, setRoomStateTick] = useState(0);
useEffect(() => { useEffect(() => {
const bumpIfCandidate = (roomId: string | undefined) => { const bumpIfCandidate = (roomId: string | undefined) => {
@ -66,25 +66,40 @@ export const useDirectRooms = (): string[] => {
}; };
}, [mx, directCandidates]); }, [mx, directCandidates]);
const seen = new Set<string>(); // The output MUST be a stable reference across renders that don't change
const out: string[] = []; // input identity — `useRoomsUnread` (state/hooks/unread.ts:37) feeds this
const isVisibleDirect = (id: string): boolean => { // array into a `useCallback([rooms])` whose result is then handed to
const room = mx.getRoom(id); // `selectAtom(...)` on every render. If `rooms` identity flips each render,
return !room || !isCatalogBotControlRoom(mx, room, bots); // `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<string>();
const out: string[] = [];
const isVisibleDirect = (id: string): boolean => {
const room = mx.getRoom(id);
return !room || !isCatalogBotControlRoom(mx, room, bots);
};
orphanRooms.forEach((id) => { orphanRooms.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) { if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id); seen.add(id);
out.push(id); out.push(id);
} }
}); });
directs.forEach((id) => { directs.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) { if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id); seen.add(id);
out.push(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]);
}; };

View file

@ -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<string | undefined>(undefined);