// Minimal matrix-widget-api transport implemented inline. We don't pull // the full SDK because: // - it's CommonJS and forces ESM interop juggling that we hit on the // dev fixture in the Telegram widget's M2 phase (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; // `m.room.redaction` events carry `redacts` at the top level (room v < 11) // and/or inside `content.redacts` (v11+). The host driver mirrors at both // for forward-compat; the widget-side parser reads either. redacts?: string; }; 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 }>; } // Open an external URL via the host. The host receives this on a // SEPARATE message channel (`api: io.vojo.bot-widget`) — distinct from // matrix-widget-api's `fromWidget` so it doesn't route through // ClientWidgetApi's request/response machinery. // // Why this exists: cross-origin iframes inside Capacitor's Android // WebView silently drop `` clicks — the WebView // doesn't have a multi-window concept, and the host's global // `setupExternalLinkHandler` (utils/capacitor.ts) only sees clicks // inside the host document, not inside the iframe (cross-origin // events don't bubble across the frame boundary). The widget posts // this message instead; the host calls `openExternalUrl(url)` which // routes to `Browser.open` on native and `window.open` on web. public openExternalUrl(url: string): void { window.parent.postMessage( { api: 'io.vojo.bot-widget', action: 'open-external-url', data: { url }, }, this.bootstrap.parentOrigin ); } // 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 number) 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); } // 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. // // `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR // post-scan cleanup events. `msgtype` is honoured only for m.room.message // (matches the driver's `readRoomTimeline` semantics). public async readTimeline(opts: { limit: number; type?: 'm.room.message' | 'm.room.redaction'; msgtype?: 'm.text' | 'm.notice' | 'm.image'; }): Promise { const data: Record = { type: opts.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-wa-${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. Forward `m.room.message` (carries the // bot's notices / errors / `m.image` QR-login broadcasts AND the // pairing-code text) AND `m.room.redaction` (post-scan QR cleanup, // see BotWidgetDriver `sanitizeBotWidgetRedactionEvent`). State // events (m.room.member) also arrive on this channel — we still // ignore them here. const data = msg.data as Partial | undefined; if ( data && data.event_id && (data.type === 'm.room.message' || data.type === 'm.room.redaction') ) { this.emit('liveEvent', data as RoomEvent); } this.replyTo(msg, {}); return; } case 'update_state': { // Initial room state push from host (m.room.member members). // We don't use these yet; 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 the host's BotWidgetDriver.getBotWidgetCapabilities. // Anything else is silently dropped by the host's validateCapabilities. // // `m.image` and `m.room.redaction` are the QR-login additions (already in // place from the Telegram widget M13). The host sanitizer for `m.image` // strips `url` / `file` / `info`, leaving only `body` (the bridge encodes // the QR payload there) plus `m.relates_to` / `m.new_content` for QR // rotation edits. Redactions signal that the QR was consumed by a // successful scan. 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.event:m.room.message#m.image', 'org.matrix.msc2762.receive.event:m.room.redaction', 'org.matrix.msc2762.receive.state_event:m.room.member', ];