import { type Capability, type IReadEventRelationsResult, type IRoomEvent, type ISendEventDetails, WidgetDriver, } from 'matrix-widget-api'; import { Direction, EventType, type IContent, type MatrixClient, type MatrixEvent, type StateEvents, type TimelineEvents, } from 'matrix-js-sdk'; import { PROTECTED_STATE_TYPES, type WidgetPermissionRequest, classifyWidgetCapabilities, loadWidgetConsent, saveWidgetConsent, } from './widgetUtils'; /** Shows the consent prompt; resolves with the capabilities the user allowed. */ export type AskWidgetPermissions = ( requests: WidgetPermissionRequest[], ) => Promise<{ allowed: Set; remember: boolean }>; type WidgetIdentity = { id: string; url: string }; /** * WidgetDriver for general room widgets. Display-only capabilities are granted * silently. Reading or sending events/state in the widget's own room needs the * user's OK through `ask` (remembered per viewer when they choose). Everything * else is denied — see classifyWidgetCapabilities. The data methods below are * only reached for capabilities the user granted (ClientWidgetApi checks), and * additionally refuse any room other than the widget's own. */ export class GeneralWidgetDriver extends WidgetDriver { public constructor( private readonly mx: MatrixClient, private readonly roomId: string, private readonly widget: WidgetIdentity, private readonly ask: AskWidgetPermissions, ) { super(); } public async validateCapabilities(requested: Set): Promise> { const { auto, ask } = classifyWidgetCapabilities(requested); const granted = new Set(auto); if (ask.length === 0) return granted; const stored = loadWidgetConsent(this.roomId, this.widget.id, this.widget.url); const remembered = new Set([...(stored?.allowed ?? []), ...(stored?.denied ?? [])]); ask.forEach((r) => { if (stored?.allowed.includes(r.capability)) granted.add(r.capability); }); const unanswered = ask.filter((r) => !remembered.has(r.capability)); if (unanswered.length === 0) return granted; const { allowed, remember } = await this.ask(unanswered); unanswered.forEach((r) => { if (allowed.has(r.capability)) granted.add(r.capability); }); if (remember) { saveWidgetConsent(this.roomId, this.widget.id, { url: this.widget.url, allowed: [ ...(stored?.allowed ?? []), ...unanswered.filter((r) => allowed.has(r.capability)).map((r) => r.capability), ], denied: [ ...(stored?.denied ?? []), ...unanswered.filter((r) => !allowed.has(r.capability)).map((r) => r.capability), ], }); } return granted; } private assertOwnRoom(roomId: string | null | undefined): string { const target = roomId || this.roomId; if (target !== this.roomId) throw new Error('Widgets can only access their own room'); return target; } public async sendEvent( eventType: string, content: IContent, stateKey: string | null = null, targetRoomId: string | null = null, ): Promise { const roomId = this.assertOwnRoom(targetRoomId); let r: { event_id: string }; if (typeof stateKey === 'string') { if (PROTECTED_STATE_TYPES.has(eventType)) { throw new Error(`Widgets may not change ${eventType}`); } r = await this.mx.sendStateEvent( roomId, eventType as keyof StateEvents, content as StateEvents[keyof StateEvents], stateKey, ); } else if (eventType === EventType.RoomRedaction) { 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 }; } public async readRoomTimeline( roomId: string, eventType: string, msgtype: string | undefined, stateKey: string | undefined, limit: number, since: string | undefined, ): Promise { const room = this.mx.getRoom(this.assertOwnRoom(roomId)); if (!room) return []; const max = limit > 0 ? limit : Number.MAX_SAFE_INTEGER; const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); for (let i = events.length - 1; i >= 0 && results.length < max; i -= 1) { const ev = events[i]; 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 readRoomState( roomId: string, eventType: string, stateKey: string | undefined, ): Promise { const room = this.mx.getRoom(this.assertOwnRoom(roomId)); const state = room?.getLiveTimeline().getState(Direction.Forward); if (!state) return []; if (stateKey === undefined) { return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); } const ev = state.getStateEvents(eventType, stateKey); return ev ? [ev.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 target = this.assertOwnRoom(roomId); const { events, nextBatch, prevBatch } = await this.mx.relations( target, eventId, relationType ?? null, eventType ?? null, { from, to, limit, dir: direction as Direction }, ); return { chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent), nextBatch: nextBatch ?? undefined, prevBatch: prevBatch ?? undefined, }; } public getKnownRooms(): string[] { return [this.roomId]; } }