Files
cinny/src/app/features/room/widgets/GeneralWidgetDriver.ts
T

191 lines
6.1 KiB
TypeScript
Raw Normal View History

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<Capability>; 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<Capability>): Promise<Set<Capability>> {
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<ISendEventDetails> {
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<IRoomEvent[]> {
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<IRoomEvent[]> {
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<IReadEventRelationsResult> {
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];
}
}