279 lines
9.9 KiB
TypeScript
279 lines
9.9 KiB
TypeScript
// 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<string, unknown>;
|
|
};
|
|
|
|
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 `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 `<commandPrefix> ` (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<RoomEvent[]> {
|
|
const data: Record<string, unknown> = {
|
|
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-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<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. 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<RoomEvent> | 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<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 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',
|
|
];
|