327 lines
12 KiB
TypeScript
327 lines
12 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 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<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 `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 `<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 —
|
|
// 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<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-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<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 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<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).
|
|
// 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<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.
|
|
// 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',
|
|
];
|