import { type Capability, type ISendDelayedEventDetails, type ISendEventDetails, type IReadEventRelationsResult, type IRoomEvent, WidgetDriver, type IWidgetApiErrorResponseDataDetails, type ISearchUserDirectoryResult, type IGetMediaConfigResult, type IRtcTransportsResult, type IRtcTransport, OpenIDRequestState, SimpleObservable, IOpenIDUpdate, } from 'matrix-widget-api'; import { EventType, type IContent, MatrixError, type MatrixEvent, Direction, type SendDelayedEventResponse, type StateEvents, type TimelineEvents, MatrixClient, AutoDiscovery, } from 'matrix-js-sdk'; import { getCallCapabilities } from './utils'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; export class CallWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; private readonly mx: MatrixClient; // Memoised client .well-known fetch for getRtcTransports (MSC4515). private wellKnownPromise: Promise | undefined; public constructor( mx: MatrixClient, private inRoomId: string, ) { super(); this.mx = mx; const deviceId = mx.getDeviceId(); if (!deviceId) throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.'); this.allowedCapabilities = getCallCapabilities(inRoomId, mx.getSafeUserId(), deviceId); } public async validateCapabilities(requested: Set): Promise> { const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)); return new Set(allow); } public async sendEvent( eventType: string, content: IContent, stateKey: string | null = null, targetRoomId: string | null = null, ): Promise { const roomId = targetRoomId || this.inRoomId; let r: { event_id: string } | null; if (typeof stateKey === 'string') { r = await this.mx.sendStateEvent( roomId, eventType as keyof StateEvents, content as StateEvents[keyof StateEvents], stateKey, ); } else if (eventType === EventType.RoomRedaction) { // special case: extract the `redacts` property and call redact r = await this.mx.redactEvent(roomId, content.redacts); } else { r = await this.mx.sendEvent( roomId, eventType as keyof TimelineEvents, content as TimelineEvents[keyof TimelineEvents], ); } return { roomId, eventId: r.event_id }; } // matrix-widget-api >= 1.18 dropped the `parentDelayId` argument (MSC4140 // parent delays were removed from the spec draft); the signature is now // (delay, eventType, content, stateKey?, roomId?). public async sendDelayedEvent( delay: number, eventType: string, content: unknown, stateKey: string | null = null, targetRoomId: string | null = null, ): Promise { const roomId = targetRoomId || this.inRoomId; const delayOpts = { delay }; let r: SendDelayedEventResponse | null; if (stateKey !== null) { // state event r = await this.mx._unstable_sendDelayedStateEvent( roomId, delayOpts, eventType as keyof StateEvents, content as StateEvents[keyof StateEvents], stateKey, ); } else { // message event r = await this.mx._unstable_sendDelayedEvent( roomId, delayOpts, null, eventType as keyof TimelineEvents, content as TimelineEvents[keyof TimelineEvents], ); } return { roomId, delayId: r.delay_id, }; } public async cancelScheduledDelayedEvent(delayId: string): Promise { await this.mx._unstable_cancelScheduledDelayedEvent(delayId); } public async restartScheduledDelayedEvent(delayId: string): Promise { await this.mx._unstable_restartScheduledDelayedEvent(delayId); } public async sendScheduledDelayedEvent(delayId: string): Promise { await this.mx._unstable_sendScheduledDelayedEvent(delayId); } public async sendToDevice( eventType: string, encrypted: boolean, contentMap: { [userId: string]: { [deviceId: string]: object } }, ): Promise { if (encrypted) { const crypto = this.mx.getCrypto(); if (!crypto) throw new Error('E2EE not enabled'); // attempt to re-batch these up into a single request const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {}; for (const userId of Object.keys(contentMap)) { const userContentMap = contentMap[userId]; for (const deviceId of Object.keys(userContentMap)) { const content = userContentMap[deviceId]; const stringifiedContent = JSON.stringify(content); invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || []; invertedContentMap[stringifiedContent].push({ userId, deviceId }); } } await Promise.all( Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => { const batch = await crypto.encryptToDeviceMessages( eventType, recipients, JSON.parse(stringifiedContent), ); await this.mx.queueToDevice(batch); }), ); } else { await this.mx.queueToDevice({ eventType, batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => Object.entries(userContentMap).map(([deviceId, content]) => ({ userId, deviceId, payload: content, })), ), }); } } public async readRoomTimeline( roomId: string, eventType: string, msgtype: string | undefined, stateKey: string | undefined, limit: number, since: string | undefined, ): Promise { const safeLimit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const room = this.mx.getRoom(roomId); if (room === null) return []; const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); for (let i = events.length - 1; i >= 0; i -= 1) { const ev = events[i]; if (results.length >= safeLimit) break; if (since !== undefined && ev.getId() === since) break; if ( ev.getType() === eventType && !ev.isState() && (eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) && (ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey) ) { results.push(ev); } } return results.map((e) => e.getEffectiveEvent() as IRoomEvent); } public async askOpenID(observer: SimpleObservable): Promise { return observer.update({ state: OpenIDRequestState.Allowed, token: await this.mx.getOpenIdToken(), }); } public async readRoomState( roomId: string, eventType: string, stateKey: string | undefined, ): Promise { const room = this.mx.getRoom(roomId); if (room === null) return []; const state = room.getLiveTimeline().getState(Direction.Forward); if (state === undefined) return []; if (stateKey === undefined) return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); const event = state.getStateEvents(eventType, stateKey); return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; } public async readEventRelations( eventId: string, roomId?: string, relationType?: string, eventType?: string, from?: string, to?: string, limit?: number, direction?: 'f' | 'b', ): Promise { const dir = direction as Direction; const targetRoomId = roomId ?? this.inRoomId ?? undefined; if (typeof targetRoomId !== 'string') { throw new Error('Error while reading the current room'); } const { events, nextBatch, prevBatch } = await this.mx.relations( targetRoomId, eventId, relationType ?? null, eventType ?? null, { from, to, limit, dir }, ); return { chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent), nextBatch: nextBatch ?? undefined, prevBatch: prevBatch ?? undefined, }; } public async searchUserDirectory( searchTerm: string, limit?: number, ): Promise { const { limited, results } = await this.mx.searchUserDirectory({ term: searchTerm, limit }); return { limited, results: results.map((r) => ({ userId: r.user_id, displayName: r.display_name, avatarUrl: r.avatar_url, })), }; } /** * MSC4515: the embedded Element Call asks us for the RTC transports because * a widget can't make authenticated homeserver calls itself. Prefer the * homeserver's MSC4143 `/rtc/transports` endpoint; fall back to the * `org.matrix.msc4143.rtc_foci` list in the client .well-known (what * Synapse deployments without MSC4143 advertise today, incl. ours). * Returning an empty list makes EC report "Call is not supported". */ public async getRtcTransports(): Promise { try { const fromServer = await this.mx._unstable_getRTCTransports(); if (Array.isArray(fromServer) && fromServer.length > 0) { return { rtc_transports: fromServer as unknown as IRtcTransport[] }; } } catch { // 404 / M_UNRECOGNIZED on homeservers without MSC4143 — fall through. } // The SDK only caches the client .well-known when started with // `clientWellKnownPollPeriod`, which we don't set — so getClientWellKnown() // is normally undefined here. Fetch it ourselves (once per driver) via the // SDK's own resolver; this is exactly what Element Call did pre-0.22. let wellKnown = this.mx.getClientWellKnown() as | { 'org.matrix.msc4143.rtc_foci'?: unknown } | undefined; if (!wellKnown?.['org.matrix.msc4143.rtc_foci']) { const domain = this.mx.getDomain(); if (domain) { if (!this.wellKnownPromise) { this.wellKnownPromise = AutoDiscovery.getRawClientConfig(domain).catch(() => ({})); } wellKnown = (await this.wellKnownPromise) as typeof wellKnown; } } const foci = wellKnown?.['org.matrix.msc4143.rtc_foci']; const transports = Array.isArray(foci) ? (foci.filter( (f): f is IRtcTransport => !!f && typeof f === 'object' && typeof (f as { type?: unknown }).type === 'string', ) as IRtcTransport[]) : []; return { rtc_transports: transports }; } public async getMediaConfig(): Promise { return this.mx.getMediaConfig(); } public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { const uploadResult = await this.mx.uploadContent(file); return { contentUri: uploadResult.content_uri }; } public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { const httpUrl = mxcUrlToHttp(this.mx, contentUri, true); if (!httpUrl) { throw new Error('Call widget failed to download file! No http url!'); } const blob = await downloadMedia(httpUrl); return { file: blob }; } public getKnownRooms(): string[] { return this.mx.getVisibleRooms().map((r) => r.roomId); } // eslint-disable-next-line class-methods-use-this public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { return error instanceof MatrixError ? { matrix_api_error: error.asWidgetApiErrorData() } : undefined; } }