// Minimal matrix-widget-api transport implemented inline. Mirrors the // Telegram widget's transport (apps/widget-telegram/src/widget-api.ts); // the postMessage protocol is bot-agnostic and the host-side // BotWidgetDriver / BotWidgetEmbed treat every bot identically. // // 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 the initial probe. 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). // Legacy mautrix-discord routes management-room commands through the // bridge.commands.Processor in mautrix/go bridge/commands; outside the // management room the prefix is required, inside it's optional but stays // unambiguous when other text is present. We always send the prefix — // works in both cases, never wrong. 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. 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-discord-${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 — see telegram widget for full rationale. 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 // `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver // `sanitizeBotWidgetRedactionEvent`). 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) — ignored. 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. // The driver is bot-agnostic — the same allowlist is applied for telegram // and discord. Discord-specific additions would have to land in // BotWidgetDriver first. // // `m.image` carries the QR login URL in `content.body` (the host sanitizer // strips `url` / `file` / `info`, so only the URL string survives); we // render the QR client-side from that URL via `qrcode-generator`. // `m.room.redaction` is how the bridge signals «QR consumed by a successful // scan» — see mautrix-discord/commands.go::fnLoginQR which redacts the QR // event after the remoteauth websocket completes. 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', ];