2026-09-24 21:17:18 -04:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-07-03 13:27:23 -04:00
|
|
|
export class GeneralWidgetDriver extends WidgetDriver {
|
2026-09-24 21:17:18 -04:00
|
|
|
public constructor(
|
|
|
|
|
private readonly mx: MatrixClient,
|
|
|
|
|
private readonly roomId: string,
|
|
|
|
|
private readonly widget: WidgetIdentity,
|
|
|
|
|
private readonly ask: AskWidgetPermissions,
|
|
|
|
|
) {
|
|
|
|
|
super();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 13:27:23 -04:00
|
|
|
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
2026-09-24 21:17:18 -04:00
|
|
|
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];
|
2026-07-03 13:27:23 -04:00
|
|
|
}
|
|
|
|
|
}
|