314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
// 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<string, unknown>;
|
|
// `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<string, unknown>;
|
|
// 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<string, unknown>;
|
|
};
|
|
|
|
type FromWidgetMessage = {
|
|
api: 'fromWidget';
|
|
widgetId: string;
|
|
requestId: string;
|
|
action: string;
|
|
data: Record<string, unknown>;
|
|
response?: Record<string, unknown>;
|
|
};
|
|
|
|
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<WidgetApiEvents[K]> } = {};
|
|
|
|
private readonly pending = new Map<
|
|
string,
|
|
{ resolve: (v: Record<string, unknown>) => 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<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
|
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
|
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 `<a target="_blank">` 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 `<commandPrefix> ` (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<RoomEvent[]> {
|
|
const data: Record<string, unknown> = {
|
|
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<K extends keyof WidgetApiEvents>(
|
|
event: K,
|
|
...args: Parameters<WidgetApiEvents[K]>
|
|
): void {
|
|
const list = this.listeners[event] as
|
|
| Array<(...a: Parameters<WidgetApiEvents[K]>) => 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<string, unknown>): 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<RoomEvent> | 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<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
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',
|
|
];
|