// Minimal matrix-widget-api transport implemented inline. We don't pull // the full SDK because: // - it's CommonJS and forces ESM interop juggling we hit on the dev // fixture in Phase 2 (esm.sh wrapping made WidgetApi unavailable as // a constructor); // - the surface we use is small: capabilities reply, theme_change reply, // send_event request, read_events request, get_openid request, live // event delivery via send_event toWidget. // // Protocol shapes match // node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts // (in the host repo). Default request timeout on the host transport is // 10 s — keep that in mind for bridge-bot replies that take time. import type { WidgetBootstrap } from './bootstrap'; export type RoomEvent = { type: string; event_id: string; room_id: string; sender: string; origin_server_ts: number; content: { msgtype?: string; body?: string; [k: string]: unknown }; unsigned: Record; }; type ToWidgetMessage = { api: 'toWidget'; widgetId: string; requestId: string; action: string; data: Record; // Present when this message IS a reply to a prior toWidget request. // Per matrix-widget-api PostmessageTransport: replies preserve the original // `api` field and add `response`. Both directions follow the same shape. response?: Record; }; type FromWidgetMessage = { api: 'fromWidget'; widgetId: string; requestId: string; action: string; data: Record; response?: Record; }; export type Capability = string; export type WidgetApiEvents = { ready: () => void; liveEvent: (ev: RoomEvent) => void; themeChange: (name: 'light' | 'dark') => void; }; const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000; export class WidgetApi { private readonly listeners: { [K in keyof WidgetApiEvents]?: Array } = {}; private readonly pending = new Map< string, { resolve: (v: Record) => void; reject: (e: Error) => void } >(); private requestSeq = 0; private isReady = false; public constructor( private readonly bootstrap: WidgetBootstrap, private readonly capabilities: Capability[] ) { window.addEventListener('message', this.onMessage); } public dispose(): void { window.removeEventListener('message', this.onMessage); this.pending.forEach(({ reject }) => reject(new Error('disposed'))); this.pending.clear(); } public on(event: K, listener: WidgetApiEvents[K]): void { const list = (this.listeners[event] ??= []) as Array; list.push(listener); // `ready` is a one-shot lifecycle signal. If the handshake completed // before this listener attached (cached-bundle race: host fires the // capabilities request on iframe `load`, the WidgetApi catches and // resolves it during script init, then React's useEffect runs *after* // that and attaches the `ready` listener), replay synchronously so // App.tsx still flips `handshakeOk` and fires `list-logins`. if (event === 'ready' && this.isReady) { (listener as () => void)(); } } public sendText(body: string): Promise<{ event_id: string }> { return this.fromWidget('send_event', { type: 'm.room.message', content: { msgtype: 'm.text', body }, }) as Promise<{ event_id: string }>; } // Always prefix outbound commands with ` ` (trailing space — // bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both // the management room and any other room the bot may have been moved to. // Form-field submissions (phone / code / password) go through this same // helper because bridgev2's stored CommandState fallback only fires after // queue.go:108 routes the message — and that route also requires the // prefix outside the management room. public sendCommand(rawBody: string): Promise<{ event_id: string }> { const body = `${this.bootstrap.commandPrefix} ${rawBody}`; return this.sendText(body); } // M12.5 timeline-resume probe. Action name is MSC2876 (`read_events`); the // capability is MSC2762 timeline (already requested at construction). We // pass `room_ids: [bootstrap.roomId]` explicitly so the host's // ClientWidgetApi takes the modern code path that calls our driver's // `readRoomTimeline` (single-room cap-checked) rather than the deprecated // `readRoomEvents` fallback. Driver returns events newest-first; reversing // to chronological order is the caller's job. public async readTimeline(opts: { limit: number; msgtype?: 'm.text' | 'm.notice'; }): Promise { const data: Record = { type: 'm.room.message', limit: opts.limit, room_ids: [this.bootstrap.roomId], }; if (opts.msgtype !== undefined) data.msgtype = opts.msgtype; const res = await this.fromWidget('org.matrix.msc2876.read_events', data); return (res.events as RoomEvent[] | undefined) ?? []; } private emit( event: K, ...args: Parameters ): void { const list = this.listeners[event] as | Array<(...a: Parameters) => void> | undefined; list?.forEach((fn) => fn(...args)); } private nextRequestId(): string { this.requestSeq += 1; return `widget-tg-${Date.now()}-${this.requestSeq}`; } private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void { window.parent.postMessage(msg, this.bootstrap.parentOrigin); } private onMessage = (ev: MessageEvent): void => { if (ev.origin !== this.bootstrap.parentOrigin) return; // Source-window guard: every legit widget API message comes from the // host window that embedded our iframe — i.e. window.parent. A foreign // tab/frame on the same origin (think browser extension content // script, popup, or sibling iframe) could otherwise post a forged // message that passes the origin check. We only accept messages // whose `source` is literally `window.parent`. The `widgetId` check // a few lines down is a soft filter; this is the hard one. if (ev.source !== window.parent) return; const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined; if (!msg || typeof msg !== 'object') return; if (msg.widgetId !== this.bootstrap.widgetId) return; if (msg.api === 'toWidget') { this.handleToWidget(msg); return; } if (msg.api === 'fromWidget' && msg.response) { const pending = this.pending.get(msg.requestId); if (!pending) return; this.pending.delete(msg.requestId); const err = (msg.response as { error?: { message?: string } }).error; if (err) pending.reject(new Error(err.message ?? 'request failed')); else pending.resolve(msg.response); } }; private replyTo(msg: ToWidgetMessage, response: Record): void { this.postToHost({ api: msg.api, widgetId: msg.widgetId, requestId: msg.requestId, action: msg.action, data: msg.data, response, }); } private handleToWidget(msg: ToWidgetMessage): void { if (!msg.requestId || !msg.action) return; switch (msg.action) { case 'capabilities': { this.replyTo(msg, { capabilities: this.capabilities }); return; } case 'notify_capabilities': { this.replyTo(msg, {}); if (!this.isReady) { this.isReady = true; this.emit('ready'); } return; } case 'supported_api_versions': { this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] }); return; } case 'theme_change': { const name = (msg.data?.name as string | undefined) ?? ''; const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light'; this.emit('themeChange', themed); this.replyTo(msg, {}); return; } case 'send_event': { // Live event push from host. We forward only m.room.message — // m.room.member state updates also arrive here but we don't // surface them in M11. const data = msg.data as Partial | undefined; if (data && data.type === 'm.room.message' && data.event_id) { this.emit('liveEvent', data as RoomEvent); } this.replyTo(msg, {}); return; } case 'update_state': { // Initial room state push from host (m.room.member members). // M11 ignores this; future milestones can use it for header chrome. this.replyTo(msg, {}); return; } default: { // Be liberal — reply empty so the host's request promise resolves. this.replyTo(msg, {}); } } } private fromWidget( action: string, data: Record ): Promise> { return new Promise((resolve, reject) => { const requestId = this.nextRequestId(); this.pending.set(requestId, { resolve, reject }); this.postToHost({ api: 'fromWidget', widgetId: this.bootstrap.widgetId, requestId, action, data, }); window.setTimeout(() => { if (this.pending.has(requestId)) { this.pending.delete(requestId); reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`)); } }, FROM_WIDGET_REQUEST_TIMEOUT_MS); }); } } // Capability set must match docs/plans/bots_tab.md (Phase 3 contract) and // the host's BotWidgetDriver.getBotWidgetCapabilities. Anything else is // silently dropped by the host's validateCapabilities — keep this aligned. export const buildCapabilities = (roomId: string): Capability[] => [ `org.matrix.msc2762.timeline:${roomId}`, 'org.matrix.msc2762.send.event:m.room.message#m.text', 'org.matrix.msc2762.receive.event:m.room.message#m.text', 'org.matrix.msc2762.receive.event:m.room.message#m.notice', 'org.matrix.msc2762.receive.state_event:m.room.member', ];