vojo/apps/widget-discord/src/widget-api.ts

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',
];