1434 lines
64 KiB
TypeScript
1434 lines
64 KiB
TypeScript
/*
|
|
* Copyright 2020 - 2024 The Matrix.org Foundation C.I.C.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { EventEmitter } from "events";
|
|
|
|
import { ITransport } from "./transport/ITransport";
|
|
import { Widget } from "./models/Widget";
|
|
import { PostmessageTransport } from "./transport/PostmessageTransport";
|
|
import { WidgetApiDirection } from "./interfaces/WidgetApiDirection";
|
|
import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest";
|
|
import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction";
|
|
import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction";
|
|
import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse";
|
|
import {
|
|
Capability,
|
|
MatrixCapabilities,
|
|
getTimelineRoomIDFromCapability,
|
|
isTimelineCapability,
|
|
} from "./interfaces/Capabilities";
|
|
import { IOpenIDUpdate, ISendEventDetails, ISendDelayedEventDetails, WidgetDriver } from "./driver/WidgetDriver";
|
|
import {
|
|
ICapabilitiesActionResponseData,
|
|
INotifyCapabilitiesActionRequestData,
|
|
IRenegotiateCapabilitiesActionRequest,
|
|
} from "./interfaces/CapabilitiesAction";
|
|
import {
|
|
ISupportedVersionsActionRequest,
|
|
ISupportedVersionsActionResponseData,
|
|
} from "./interfaces/SupportedVersionsAction";
|
|
import { ApiVersion, CurrentApiVersions, UnstableApiVersion } from "./interfaces/ApiVersion";
|
|
import { IScreenshotActionResponseData } from "./interfaces/ScreenshotAction";
|
|
import { IVisibilityActionRequestData } from "./interfaces/VisibilityAction";
|
|
import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponseData } from "./interfaces/IWidgetApiResponse";
|
|
import {
|
|
IModalWidgetButtonClickedRequestData,
|
|
IModalWidgetOpenRequestData,
|
|
IModalWidgetOpenRequestDataButton,
|
|
IModalWidgetReturnData,
|
|
} from "./interfaces/ModalWidgetActions";
|
|
import {
|
|
ISendEventFromWidgetActionRequest,
|
|
ISendEventFromWidgetResponseData,
|
|
ISendEventToWidgetRequestData,
|
|
} from "./interfaces/SendEventAction";
|
|
import {
|
|
ISendToDeviceFromWidgetActionRequest,
|
|
ISendToDeviceFromWidgetResponseData,
|
|
ISendToDeviceToWidgetRequestData,
|
|
} from "./interfaces/SendToDeviceAction";
|
|
import { EventDirection, EventKind, WidgetEventCapability } from "./models/WidgetEventCapability";
|
|
import { IRoomEvent } from "./interfaces/IRoomEvent";
|
|
import {
|
|
IGetOpenIDActionRequest,
|
|
IGetOpenIDActionResponseData,
|
|
IOpenIDCredentials,
|
|
OpenIDRequestState,
|
|
} from "./interfaces/GetOpenIDAction";
|
|
import { SimpleObservable } from "./util/SimpleObservable";
|
|
import { IOpenIDCredentialsActionRequestData } from "./interfaces/OpenIDCredentialsAction";
|
|
import { INavigateActionRequest } from "./interfaces/NavigateAction";
|
|
import { IReadEventFromWidgetActionRequest, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction";
|
|
import {
|
|
ITurnServer,
|
|
IWatchTurnServersRequest,
|
|
IUnwatchTurnServersRequest,
|
|
IUpdateTurnServersRequestData,
|
|
} from "./interfaces/TurnServerActions";
|
|
import { Symbols } from "./Symbols";
|
|
import {
|
|
IReadRelationsFromWidgetActionRequest,
|
|
IReadRelationsFromWidgetResponseData,
|
|
} from "./interfaces/ReadRelationsAction";
|
|
import {
|
|
IUserDirectorySearchFromWidgetActionRequest,
|
|
IUserDirectorySearchFromWidgetResponseData,
|
|
} from "./interfaces/UserDirectorySearchAction";
|
|
import {
|
|
IReadRoomAccountDataFromWidgetActionRequest,
|
|
IReadRoomAccountDataFromWidgetResponseData,
|
|
} from "./interfaces/ReadRoomAccountDataAction";
|
|
import {
|
|
IGetMediaConfigActionFromWidgetActionRequest,
|
|
IGetMediaConfigActionFromWidgetResponseData,
|
|
} from "./interfaces/GetMediaConfigAction";
|
|
import {
|
|
IRtcTransportsFromWidgetActionRequest,
|
|
IRtcTransportsFromWidgetResponseData,
|
|
} from "./interfaces/RtcTransportsAction";
|
|
import {
|
|
IUpdateDelayedEventFromWidgetActionRequest,
|
|
UpdateDelayedEventAction,
|
|
} from "./interfaces/UpdateDelayedEventAction";
|
|
import {
|
|
IUploadFileActionFromWidgetActionRequest,
|
|
IUploadFileActionFromWidgetResponseData,
|
|
} from "./interfaces/UploadFileAction";
|
|
import {
|
|
IDownloadFileActionFromWidgetActionRequest,
|
|
IDownloadFileActionFromWidgetResponseData,
|
|
} from "./interfaces/DownloadFileAction";
|
|
import {
|
|
IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest,
|
|
IRtcLivekitDelegateDelayedLeaveFromWidgetResponseData,
|
|
IRtcLivekitGetTokenFromWidgetActionRequest,
|
|
IRtcLivekitGetTokenFromWidgetResponseData,
|
|
} from "./interfaces/RtcLivekitActions";
|
|
import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction";
|
|
import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction";
|
|
import { IToDeviceMessage } from "./interfaces/IToDeviceMessage";
|
|
|
|
/**
|
|
* API handler for the client side of widgets. This raises events
|
|
* for each action received as `action:${action}` (eg: "action:screenshot").
|
|
* Default handling can be prevented by using preventDefault() on the
|
|
* raised event. The default handling varies for each action: ones
|
|
* which the SDK can handle safely are acknowledged appropriately and
|
|
* ones which are unhandled (custom or require the client to do something)
|
|
* are rejected with an error.
|
|
*
|
|
* Events which are preventDefault()ed must reply using the transport.
|
|
* The events raised will have a default of an IWidgetApiRequest
|
|
* interface.
|
|
*
|
|
* When the ClientWidgetApi is ready to start sending requests, it will
|
|
* raise a "ready" CustomEvent. After the ready event fires, actions can
|
|
* be sent and the transport will be ready.
|
|
*
|
|
* When the widget has indicated it has loaded, this class raises a
|
|
* "preparing" CustomEvent. The preparing event does not indicate that
|
|
* the widget is ready to receive communications - that is signified by
|
|
* the ready event exclusively.
|
|
*
|
|
* This class only handles one widget at a time.
|
|
*/
|
|
export class ClientWidgetApi extends EventEmitter {
|
|
public readonly transport: ITransport;
|
|
|
|
private cachedWidgetVersions: ApiVersion[] | null = null;
|
|
// contentLoadedActionSent is used to check that only one ContentLoaded request is send.
|
|
private contentLoadedActionSent = false;
|
|
private readonly allowedCapabilities = new Set<Capability>();
|
|
private readonly allowedEvents: WidgetEventCapability[] = [];
|
|
private isStopped = false;
|
|
private turnServers: AsyncGenerator<ITurnServer> | null = null;
|
|
private contentLoadedWaitTimer?: ReturnType<typeof setTimeout>;
|
|
// Stores pending requests to push a room's state to the widget
|
|
private readonly pushRoomStateTasks = new Set<Promise<void>>();
|
|
// Room ID → event type → state key → events to be pushed
|
|
private readonly pushRoomStateResult = new Map<string, Map<string, Map<string, IRoomEvent>>>();
|
|
private flushRoomStateTask: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Creates a new client widget API. This will instantiate the transport
|
|
* and start everything. When the iframe is loaded under the widget's
|
|
* conditions, a "ready" event will be raised.
|
|
* @param {Widget} widget The widget to communicate with.
|
|
* @param {HTMLIFrameElement} iframe The iframe the widget is in.
|
|
* @param {WidgetDriver} driver The driver for this widget/client.
|
|
*/
|
|
public constructor(
|
|
public readonly widget: Widget,
|
|
iframe: HTMLIFrameElement,
|
|
private readonly driver: WidgetDriver,
|
|
) {
|
|
super();
|
|
if (!iframe?.contentWindow) {
|
|
throw new Error("No iframe supplied");
|
|
}
|
|
if (!widget) {
|
|
throw new Error("Invalid widget");
|
|
}
|
|
if (!driver) {
|
|
throw new Error("Invalid driver");
|
|
}
|
|
this.transport = new PostmessageTransport(
|
|
WidgetApiDirection.ToWidget,
|
|
widget.id,
|
|
iframe.contentWindow,
|
|
globalThis,
|
|
);
|
|
this.transport.targetOrigin = widget.origin;
|
|
this.transport.on("message", this.handleMessage.bind(this));
|
|
|
|
iframe.addEventListener("load", this.onIframeLoad.bind(this));
|
|
|
|
this.transport.start();
|
|
}
|
|
|
|
public hasCapability(capability: Capability): boolean {
|
|
return this.allowedCapabilities.has(capability);
|
|
}
|
|
|
|
public canUseRoomTimeline(roomId: string | Symbols.AnyRoom): boolean {
|
|
return (
|
|
this.hasCapability(`org.matrix.msc2762.timeline:${Symbols.AnyRoom}`) ||
|
|
this.hasCapability(`org.matrix.msc2762.timeline:${roomId}`)
|
|
);
|
|
}
|
|
|
|
public canSendRoomEvent(eventType: string, msgtype: string | null = null): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsRoomEvent(EventDirection.Send, eventType, msgtype));
|
|
}
|
|
|
|
public canSendStateEvent(eventType: string, stateKey: string): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsStateEvent(EventDirection.Send, eventType, stateKey));
|
|
}
|
|
|
|
public canSendToDeviceEvent(eventType: string): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsToDeviceEvent(EventDirection.Send, eventType));
|
|
}
|
|
|
|
public canReceiveRoomEvent(eventType: string, msgtype: string | null = null): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsRoomEvent(EventDirection.Receive, eventType, msgtype));
|
|
}
|
|
|
|
public canReceiveStateEvent(eventType: string, stateKey: string | null): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsStateEvent(EventDirection.Receive, eventType, stateKey));
|
|
}
|
|
|
|
public canReceiveToDeviceEvent(eventType: string): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsToDeviceEvent(EventDirection.Receive, eventType));
|
|
}
|
|
|
|
public canReceiveRoomAccountData(eventType: string): boolean {
|
|
return this.allowedEvents.some((e) => e.matchesAsRoomAccountData(EventDirection.Receive, eventType));
|
|
}
|
|
|
|
public stop(): void {
|
|
this.isStopped = true;
|
|
this.transport.stop();
|
|
}
|
|
|
|
public async getWidgetVersions(): Promise<ApiVersion[]> {
|
|
if (Array.isArray(this.cachedWidgetVersions)) {
|
|
return this.cachedWidgetVersions;
|
|
}
|
|
|
|
try {
|
|
const r = await this.transport.send<IWidgetApiRequestEmptyData, ISupportedVersionsActionResponseData>(
|
|
WidgetApiToWidgetAction.SupportedApiVersions,
|
|
{},
|
|
);
|
|
this.cachedWidgetVersions = r.supported_versions;
|
|
return r.supported_versions;
|
|
} catch (e) {
|
|
console.warn("non-fatal error getting supported widget versions: ", e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private beginCapabilities(): void {
|
|
// widget has loaded - tell all the listeners that
|
|
this.emit("preparing");
|
|
|
|
let requestedCaps: Capability[];
|
|
this.transport
|
|
.send<IWidgetApiRequestEmptyData, ICapabilitiesActionResponseData>(WidgetApiToWidgetAction.Capabilities, {})
|
|
.then((caps) => {
|
|
requestedCaps = caps.capabilities;
|
|
return this.driver.validateCapabilities(new Set(caps.capabilities));
|
|
})
|
|
.then((allowedCaps) => {
|
|
this.allowCapabilities([...allowedCaps], requestedCaps);
|
|
this.emit("ready");
|
|
})
|
|
.catch((e) => {
|
|
this.emit("error:preparing", e);
|
|
});
|
|
}
|
|
|
|
private allowCapabilities(allowed: string[], requested: string[]): void {
|
|
console.log(`Widget ${this.widget.id} is allowed capabilities:`, allowed);
|
|
|
|
for (const c of allowed) this.allowedCapabilities.add(c);
|
|
const allowedEvents = WidgetEventCapability.findEventCapabilities(allowed);
|
|
this.allowedEvents.push(...allowedEvents);
|
|
|
|
this.transport
|
|
.send(WidgetApiToWidgetAction.NotifyCapabilities, <INotifyCapabilitiesActionRequestData>{
|
|
requested,
|
|
approved: Array.from(this.allowedCapabilities),
|
|
})
|
|
.catch((e) => {
|
|
console.warn("non-fatal error notifying widget of approved capabilities:", e);
|
|
})
|
|
.then(() => {
|
|
this.emit("capabilitiesNotified");
|
|
});
|
|
|
|
// Push the initial room state for all rooms with a timeline capability
|
|
for (const c of allowed) {
|
|
if (isTimelineCapability(c)) {
|
|
const roomId = getTimelineRoomIDFromCapability(c);
|
|
if (roomId === Symbols.AnyRoom) {
|
|
for (const roomId of this.driver.getKnownRooms()) {
|
|
this.pushRoomState(roomId);
|
|
}
|
|
} else {
|
|
this.pushRoomState(roomId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allowed.includes(MatrixCapabilities.MSC4407ReceiveStickyEvent)) {
|
|
console.debug(`Widget ${this.widget.id} is allowed to receive sticky events, check current sticky state.`);
|
|
// If the widget can receive sticky events, push all sticky events in known rooms now.
|
|
// Sticky events are like a state, and passed history is needed to get the full state.
|
|
const roomIds = allowed
|
|
.filter((capability) => isTimelineCapability(capability))
|
|
.map((timelineCapability) => getTimelineRoomIDFromCapability(timelineCapability))
|
|
.flatMap((roomIdOrWildcard) => {
|
|
if (roomIdOrWildcard === Symbols.AnyRoom) {
|
|
// Do we support getting sticky state for any room?
|
|
return this.driver.getKnownRooms();
|
|
} else {
|
|
return roomIdOrWildcard;
|
|
}
|
|
});
|
|
console.debug(`Widget ${this.widget.id} is allowed to receive sticky events in rooms:`, roomIds);
|
|
|
|
for (const roomId of roomIds) {
|
|
this.pushStickyState(roomId).catch((err) => {
|
|
console.error(`Failed to push sticky events to widget ${this.widget.id} for room ${roomId}:`, err);
|
|
});
|
|
}
|
|
}
|
|
|
|
// If new events are allowed and the currently viewed room isn't covered
|
|
// by a timeline capability, then we know that there could be some state
|
|
// in the viewed room that the widget hasn't learned about yet- push it.
|
|
if (allowedEvents.length > 0 && this.viewedRoomId !== null && !this.canUseRoomTimeline(this.viewedRoomId)) {
|
|
this.pushRoomState(this.viewedRoomId);
|
|
}
|
|
}
|
|
|
|
private onIframeLoad(ev: Event): void {
|
|
if (this.widget.waitForIframeLoad) {
|
|
// If the widget is set to waitForIframeLoad the capabilities immediately get setup after load.
|
|
// The client does not wait for the ContentLoaded action.
|
|
this.beginCapabilities();
|
|
} else {
|
|
// Reaching this means, that the Iframe got reloaded/loaded and
|
|
// the clientApi is awaiting the FIRST ContentLoaded action.
|
|
console.log("waitForIframeLoad is false: waiting for widget to send contentLoaded");
|
|
this.contentLoadedWaitTimer = setTimeout(() => {
|
|
console.error(
|
|
"Widget specified waitForIframeLoad=false but timed out waiting for contentLoaded event!",
|
|
);
|
|
}, 10000);
|
|
this.contentLoadedActionSent = false;
|
|
}
|
|
}
|
|
|
|
private handleContentLoadedAction(action: IContentLoadedActionRequest): void {
|
|
if (this.contentLoadedWaitTimer !== undefined) {
|
|
clearTimeout(this.contentLoadedWaitTimer);
|
|
this.contentLoadedWaitTimer = undefined;
|
|
}
|
|
if (this.contentLoadedActionSent) {
|
|
throw new Error(
|
|
"Improper sequence: ContentLoaded Action can only be sent once after the widget loaded " +
|
|
"and should only be used if waitForIframeLoad is false (default=true)",
|
|
);
|
|
}
|
|
if (this.widget.waitForIframeLoad) {
|
|
this.transport.reply(action, <IWidgetApiErrorResponseData>{
|
|
error: {
|
|
message:
|
|
"Improper sequence: not expecting ContentLoaded event if " +
|
|
"waitForIframeLoad is true (default=true)",
|
|
},
|
|
});
|
|
} else {
|
|
this.transport.reply(action, <IWidgetApiRequestEmptyData>{});
|
|
this.beginCapabilities();
|
|
}
|
|
this.contentLoadedActionSent = true;
|
|
}
|
|
|
|
private replyVersions(request: ISupportedVersionsActionRequest): void {
|
|
this.transport.reply<ISupportedVersionsActionResponseData>(request, {
|
|
supported_versions: CurrentApiVersions,
|
|
});
|
|
}
|
|
|
|
private async supportsUpdateState(): Promise<boolean> {
|
|
return (await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
|
|
}
|
|
|
|
private handleCapabilitiesRenegotiate(request: IRenegotiateCapabilitiesActionRequest): void {
|
|
// acknowledge first
|
|
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
|
|
const requested = request.data?.capabilities || [];
|
|
const newlyRequested = new Set(requested.filter((r) => !this.hasCapability(r)));
|
|
if (newlyRequested.size === 0) {
|
|
// Nothing to do - skip validation
|
|
this.allowCapabilities([], []);
|
|
}
|
|
|
|
this.driver
|
|
.validateCapabilities(newlyRequested)
|
|
.then((allowed) => this.allowCapabilities([...allowed], [...newlyRequested]));
|
|
}
|
|
|
|
private handleNavigate(request: INavigateActionRequest): void {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC2931Navigate)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
if (!request.data?.uri.startsWith("https://matrix.to/#")) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid matrix.to URI" },
|
|
});
|
|
}
|
|
|
|
const onErr = (e: unknown): void => {
|
|
console.error("[ClientWidgetApi] Failed to handle navigation: ", e);
|
|
this.handleDriverError(e, request, "Error handling navigation");
|
|
};
|
|
|
|
try {
|
|
this.driver
|
|
.navigate(request.data.uri.toString())
|
|
.catch((e: unknown) => onErr(e))
|
|
.then(() => {
|
|
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
});
|
|
} catch (e) {
|
|
return onErr(e);
|
|
}
|
|
}
|
|
|
|
private handleOIDC(request: IGetOpenIDActionRequest): void {
|
|
let phase = 1; // 1 = initial request, 2 = after user manual confirmation
|
|
|
|
const replyState = (
|
|
state: OpenIDRequestState,
|
|
credential?: IOpenIDCredentials,
|
|
): void | Promise<IWidgetApiAcknowledgeResponseData> => {
|
|
credential = credential || {};
|
|
if (phase > 1) {
|
|
return this.transport.send<IOpenIDCredentialsActionRequestData>(
|
|
WidgetApiToWidgetAction.OpenIDCredentials,
|
|
{
|
|
state: state,
|
|
original_request_id: request.requestId,
|
|
...credential,
|
|
},
|
|
);
|
|
} else {
|
|
return this.transport.reply<IGetOpenIDActionResponseData>(request, {
|
|
state: state,
|
|
...credential,
|
|
});
|
|
}
|
|
};
|
|
|
|
const replyError = (msg: string): void | Promise<IWidgetApiAcknowledgeResponseData> => {
|
|
console.error("[ClientWidgetApi] Failed to handle OIDC: ", msg);
|
|
if (phase > 1) {
|
|
// We don't have a way to indicate that a random error happened in this flow, so
|
|
// just block the attempt.
|
|
return replyState(OpenIDRequestState.Blocked);
|
|
} else {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: msg },
|
|
});
|
|
}
|
|
};
|
|
|
|
const observer = new SimpleObservable<IOpenIDUpdate>((update) => {
|
|
if (update.state === OpenIDRequestState.PendingUserConfirmation && phase > 1) {
|
|
observer.close();
|
|
return replyError("client provided out-of-phase response to OIDC flow");
|
|
}
|
|
|
|
if (update.state === OpenIDRequestState.PendingUserConfirmation) {
|
|
replyState(update.state);
|
|
phase++;
|
|
return;
|
|
}
|
|
|
|
if (update.state === OpenIDRequestState.Allowed && !update.token) {
|
|
return replyError("client provided invalid OIDC token for an allowed request");
|
|
}
|
|
if (update.state === OpenIDRequestState.Blocked) {
|
|
update.token = undefined; // just in case the client did something weird
|
|
}
|
|
|
|
observer.close();
|
|
return replyState(update.state, update.token);
|
|
});
|
|
|
|
this.driver.askOpenID(observer);
|
|
}
|
|
|
|
private handleReadRoomAccountData(request: IReadRoomAccountDataFromWidgetActionRequest): void | Promise<void> {
|
|
const events = this.driver.readRoomAccountData(request.data.type);
|
|
|
|
if (!this.canReceiveRoomAccountData(request.data.type)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot read room account data of this type" },
|
|
});
|
|
}
|
|
|
|
return events.then((evs) => {
|
|
this.transport.reply<IReadRoomAccountDataFromWidgetResponseData>(request, { events: evs });
|
|
});
|
|
}
|
|
|
|
private async handleReadEvents(request: IReadEventFromWidgetActionRequest): Promise<void> {
|
|
if (!request.data.type) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing event type" },
|
|
});
|
|
}
|
|
if (request.data.limit !== undefined && (!request.data.limit || request.data.limit < 0)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - limit out of range" },
|
|
});
|
|
}
|
|
|
|
let askRoomIds: string[];
|
|
if (request.data.room_ids === undefined) {
|
|
askRoomIds = this.viewedRoomId === null ? [] : [this.viewedRoomId];
|
|
} else if (request.data.room_ids === Symbols.AnyRoom) {
|
|
askRoomIds = this.driver.getKnownRooms().filter((roomId) => this.canUseRoomTimeline(roomId));
|
|
} else {
|
|
askRoomIds = request.data.room_ids;
|
|
for (const roomId of askRoomIds) {
|
|
if (!this.canUseRoomTimeline(roomId)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: `Unable to access room timeline: ${roomId}` },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const limit = request.data.limit || 0;
|
|
const since = request.data.since;
|
|
|
|
let stateKey: string | undefined = undefined;
|
|
let msgtype: string | undefined = undefined;
|
|
if (request.data.state_key !== undefined) {
|
|
stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString();
|
|
if (!this.canReceiveStateEvent(request.data.type, stateKey ?? null)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot read state events of this type" },
|
|
});
|
|
}
|
|
} else {
|
|
msgtype = request.data.msgtype;
|
|
if (!this.canReceiveRoomEvent(request.data.type, msgtype)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot read room events of this type" },
|
|
});
|
|
}
|
|
}
|
|
|
|
let events: IRoomEvent[];
|
|
|
|
if (request.data.room_ids === undefined && askRoomIds.length === 0) {
|
|
// For backwards compatibility we still call the deprecated
|
|
// readRoomEvents and readStateEvents methods in case the client isn't
|
|
// letting us know the currently viewed room via setViewedRoomId
|
|
//
|
|
// This can be considered as a deprecated implementation.
|
|
// A driver should call `setViewedRoomId` on the widget messaging and implement the new readRoomState and readRoomTimeline
|
|
// Methods.
|
|
// This block makes sure that it is also possible to not use setViewedRoomId.
|
|
// readRoomTimeline and readRoomState are required however! Otherwise widget requests that include
|
|
// `room_ids` will fail.
|
|
console.warn(
|
|
"The widgetDriver uses deprecated behaviour:\n It does not set the viewedRoomId using `setViewedRoomId`",
|
|
);
|
|
events = await // This returns [] with the current driver of Element Web.
|
|
// Add default implementations of the `readRoomEvents` and `readStateEvents`
|
|
// methods to use `readRoomTimeline` and `readRoomState` if they are not overwritten.
|
|
(request.data.state_key === undefined
|
|
? this.driver.readRoomEvents(request.data.type, msgtype, limit, null, since)
|
|
: this.driver.readStateEvents(request.data.type, stateKey, limit, null));
|
|
} else if (await this.supportsUpdateState()) {
|
|
// Calling read_events with a stateKey still reads from the rooms timeline (not the room state).
|
|
events = (
|
|
await Promise.all(
|
|
askRoomIds.map((roomId) =>
|
|
this.driver.readRoomTimeline(roomId, request.data.type, msgtype, stateKey, limit, since),
|
|
),
|
|
)
|
|
).flat(1);
|
|
} else {
|
|
// TODO: remove this once `UnstableApiVersion.MSC2762_UPDATE_STATE` becomes stable.
|
|
// Before version `MSC2762_UPDATE_STATE` we used readRoomState for read_events actions.
|
|
events = (
|
|
request.data.state_key === undefined
|
|
? await Promise.all(
|
|
askRoomIds.map((roomId) =>
|
|
this.driver.readRoomTimeline(roomId, request.data.type, msgtype, stateKey, limit, since),
|
|
),
|
|
)
|
|
: await Promise.all(
|
|
askRoomIds.map((roomId) => this.driver.readRoomState(roomId, request.data.type, stateKey)),
|
|
)
|
|
).flat(1);
|
|
}
|
|
|
|
this.transport.reply<IReadEventFromWidgetResponseData>(request, { events });
|
|
}
|
|
|
|
private handleSendEvent(request: ISendEventFromWidgetActionRequest): void {
|
|
if (!request.data.type) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing event type" },
|
|
});
|
|
}
|
|
|
|
if (!!request.data.room_id && !this.canUseRoomTimeline(request.data.room_id)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: `Unable to access room timeline: ${request.data.room_id}` },
|
|
});
|
|
}
|
|
|
|
if (request.data.parent_delay_id !== undefined) {
|
|
console.warn("Data includes parent_delay_id, but the widgetDriver ignores it");
|
|
}
|
|
|
|
const delay = request.data.delay;
|
|
if (delay !== undefined && !this.hasCapability(MatrixCapabilities.MSC4157SendDelayedEvent)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: `Missing capability for ${MatrixCapabilities.MSC4157SendDelayedEvent}` },
|
|
});
|
|
}
|
|
|
|
const isStickyEvent = request.data.sticky_duration_ms !== undefined;
|
|
if (isStickyEvent && !this.hasCapability(MatrixCapabilities.MSC4407SendStickyEvent)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: `Missing capability for ${MatrixCapabilities.MSC4407SendStickyEvent}` },
|
|
});
|
|
}
|
|
|
|
let sendEventPromise: Promise<ISendEventDetails | ISendDelayedEventDetails>;
|
|
if (request.data.state_key !== undefined) {
|
|
if (!this.canSendStateEvent(request.data.type, request.data.state_key)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot send state events of this type" },
|
|
});
|
|
}
|
|
if (isStickyEvent) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot send a state event with a sticky duration" },
|
|
});
|
|
}
|
|
|
|
if (delay !== undefined) {
|
|
sendEventPromise = this.driver.sendDelayedEvent(
|
|
delay,
|
|
request.data.type,
|
|
request.data.content || {},
|
|
request.data.state_key,
|
|
request.data.room_id,
|
|
);
|
|
} else {
|
|
sendEventPromise = this.driver.sendEvent(
|
|
request.data.type,
|
|
request.data.content || {},
|
|
request.data.state_key,
|
|
request.data.room_id,
|
|
);
|
|
}
|
|
} else {
|
|
const content = (request.data.content as { msgtype?: string }) || {};
|
|
const msgtype = content["msgtype"];
|
|
if (!this.canSendRoomEvent(request.data.type, msgtype)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot send room events of this type" },
|
|
});
|
|
}
|
|
|
|
// Events can be sticky, delayed, both, or neither. The following
|
|
// section of code takes the common parameters and uses the correct
|
|
// function depending on the request type.
|
|
|
|
const params: Parameters<WidgetDriver["sendEvent"]> = [
|
|
request.data.type,
|
|
content,
|
|
null, // not sending a state event
|
|
request.data.room_id,
|
|
];
|
|
|
|
if (delay !== undefined && request.data.sticky_duration_ms) {
|
|
sendEventPromise = this.driver.sendDelayedStickyEvent(
|
|
delay,
|
|
request.data.sticky_duration_ms,
|
|
request.data.type,
|
|
content,
|
|
request.data.room_id,
|
|
);
|
|
} else if (delay !== undefined) {
|
|
sendEventPromise = this.driver.sendDelayedEvent(delay, ...params);
|
|
} else if (request.data.sticky_duration_ms) {
|
|
sendEventPromise = this.driver.sendStickyEvent(
|
|
request.data.sticky_duration_ms,
|
|
request.data.type,
|
|
content,
|
|
request.data.room_id,
|
|
);
|
|
} else {
|
|
sendEventPromise = this.driver.sendEvent(...params);
|
|
}
|
|
}
|
|
|
|
sendEventPromise
|
|
.then((sentEvent) => {
|
|
return this.transport.reply<ISendEventFromWidgetResponseData>(request, {
|
|
room_id: sentEvent.roomId,
|
|
...("eventId" in sentEvent
|
|
? {
|
|
event_id: sentEvent.eventId,
|
|
}
|
|
: {
|
|
delay_id: sentEvent.delayId,
|
|
}),
|
|
});
|
|
})
|
|
.catch((e: unknown) => {
|
|
console.error("error sending event: ", e);
|
|
this.handleDriverError(e, request, "Error sending event");
|
|
});
|
|
}
|
|
|
|
private handleUpdateDelayedEvent(request: IUpdateDelayedEventFromWidgetActionRequest): void {
|
|
if (!request.data.delay_id) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing delay_id" },
|
|
});
|
|
}
|
|
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
let updateDelayedEvent: (delayId: string) => Promise<void>;
|
|
switch (request.data.action) {
|
|
case UpdateDelayedEventAction.Cancel:
|
|
updateDelayedEvent = this.driver.cancelScheduledDelayedEvent;
|
|
break;
|
|
case UpdateDelayedEventAction.Restart:
|
|
updateDelayedEvent = this.driver.restartScheduledDelayedEvent;
|
|
break;
|
|
case UpdateDelayedEventAction.Send:
|
|
updateDelayedEvent = this.driver.sendScheduledDelayedEvent;
|
|
break;
|
|
default:
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - unsupported action" },
|
|
});
|
|
}
|
|
updateDelayedEvent
|
|
.call(this.driver, request.data.delay_id)
|
|
.then(() => {
|
|
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
})
|
|
.catch((e: unknown) => {
|
|
console.error("error updating delayed event: ", e);
|
|
this.handleDriverError(e, request, "Error updating delayed event");
|
|
});
|
|
}
|
|
|
|
private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise<void> {
|
|
if (!request.data.type) {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing event type" },
|
|
});
|
|
} else if (!request.data.messages) {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing event contents" },
|
|
});
|
|
} else if (typeof request.data.encrypted !== "boolean") {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing encryption flag" },
|
|
});
|
|
} else if (!this.canSendToDeviceEvent(request.data.type)) {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Cannot send to-device events of this type" },
|
|
});
|
|
} else {
|
|
try {
|
|
await this.driver.sendToDevice(request.data.type, request.data.encrypted, request.data.messages);
|
|
this.transport.reply<ISendToDeviceFromWidgetResponseData>(request, {});
|
|
} catch (e) {
|
|
console.error("error sending to-device event", e);
|
|
this.handleDriverError(e, request, "Error sending event");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async pollTurnServers(turnServers: AsyncGenerator<ITurnServer>, initialServer: ITurnServer): Promise<void> {
|
|
try {
|
|
await this.transport.send<IUpdateTurnServersRequestData>(
|
|
WidgetApiToWidgetAction.UpdateTurnServers,
|
|
initialServer as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature
|
|
);
|
|
|
|
// Pick the generator up where we left off
|
|
for await (const server of turnServers) {
|
|
await this.transport.send<IUpdateTurnServersRequestData>(
|
|
WidgetApiToWidgetAction.UpdateTurnServers,
|
|
server as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error("error polling for TURN servers", e);
|
|
}
|
|
}
|
|
|
|
private async handleWatchTurnServers(request: IWatchTurnServersRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
} else if (this.turnServers) {
|
|
// We're already polling, so this is a no-op
|
|
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
} else {
|
|
try {
|
|
const turnServers = this.driver.getTurnServers();
|
|
|
|
// Peek at the first result, so we can at least verify that the
|
|
// client isn't banned from getting TURN servers entirely
|
|
const { done, value } = await turnServers.next();
|
|
if (done) throw new Error("Client refuses to provide any TURN servers");
|
|
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
|
|
// Start the poll loop, sending the widget the initial result
|
|
this.pollTurnServers(turnServers, value);
|
|
this.turnServers = turnServers;
|
|
} catch (e) {
|
|
console.error("error getting first TURN server results", e);
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "TURN servers not available" },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleUnwatchTurnServers(request: IUnwatchTurnServersRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) {
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
} else if (!this.turnServers) {
|
|
// We weren't polling anyways, so this is a no-op
|
|
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
} else {
|
|
// Stop the generator, allowing it to clean up
|
|
await this.turnServers.return(undefined);
|
|
this.turnServers = null;
|
|
this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
|
|
}
|
|
}
|
|
|
|
private async handleReadRelations(request: IReadRelationsFromWidgetActionRequest): Promise<void> {
|
|
if (!request.data.event_id) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing event ID" },
|
|
});
|
|
}
|
|
|
|
if (request.data.limit !== undefined && request.data.limit < 0) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - limit out of range" },
|
|
});
|
|
}
|
|
|
|
if (request.data.room_id !== undefined && !this.canUseRoomTimeline(request.data.room_id)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: `Unable to access room timeline: ${request.data.room_id}` },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.readEventRelations(
|
|
request.data.event_id,
|
|
request.data.room_id,
|
|
request.data.rel_type,
|
|
request.data.event_type,
|
|
request.data.from,
|
|
request.data.to,
|
|
request.data.limit,
|
|
request.data.direction,
|
|
);
|
|
|
|
// only return events that the user has the permission to receive
|
|
const chunk = result.chunk.filter((e) => {
|
|
if (e.state_key !== undefined) {
|
|
return this.canReceiveStateEvent(e.type, e.state_key);
|
|
} else {
|
|
return this.canReceiveRoomEvent(e.type, (e.content as { msgtype?: string })["msgtype"]);
|
|
}
|
|
});
|
|
|
|
return this.transport.reply<IReadRelationsFromWidgetResponseData>(request, {
|
|
chunk,
|
|
prev_batch: result.prevBatch,
|
|
next_batch: result.nextBatch,
|
|
});
|
|
} catch (e) {
|
|
console.error("error getting the relations", e);
|
|
this.handleDriverError(e, request, "Unexpected error while reading relations");
|
|
}
|
|
}
|
|
|
|
private async handleUserDirectorySearch(request: IUserDirectorySearchFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC3973UserDirectorySearch)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
if (typeof request.data.search_term !== "string") {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - missing search term" },
|
|
});
|
|
}
|
|
|
|
if (request.data.limit !== undefined && request.data.limit < 0) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Invalid request - limit out of range" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.searchUserDirectory(request.data.search_term, request.data.limit);
|
|
|
|
return this.transport.reply<IUserDirectorySearchFromWidgetResponseData>(request, {
|
|
limited: result.limited,
|
|
results: result.results.map((r) => ({
|
|
user_id: r.userId,
|
|
display_name: r.displayName,
|
|
avatar_url: r.avatarUrl,
|
|
})),
|
|
});
|
|
} catch (e) {
|
|
console.error("error searching in the user directory", e);
|
|
this.handleDriverError(e, request, "Unexpected error while searching in the user directory");
|
|
}
|
|
}
|
|
|
|
private async handleGetMediaConfig(request: IGetMediaConfigActionFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4039UploadFile)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.getMediaConfig();
|
|
|
|
return this.transport.reply<IGetMediaConfigActionFromWidgetResponseData>(request, result);
|
|
} catch (e) {
|
|
console.error("error while getting the media configuration", e);
|
|
this.handleDriverError(e, request, "Unexpected error while getting the media configuration");
|
|
}
|
|
}
|
|
|
|
private async handleRtcTransports(request: IRtcTransportsFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4515RtcTransports)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.getRtcTransports();
|
|
|
|
return this.transport.reply<IRtcTransportsFromWidgetResponseData>(request, {
|
|
rtc_transports: result.rtc_transports,
|
|
});
|
|
} catch (e) {
|
|
console.error("error while getting the RTC transports", e);
|
|
this.handleDriverError(e, request, "Unexpected error while getting the RTC transports");
|
|
}
|
|
}
|
|
|
|
private async handleUploadFile(request: IUploadFileActionFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4039UploadFile)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.uploadFile(request.data.file);
|
|
|
|
return this.transport.reply<IUploadFileActionFromWidgetResponseData>(request, {
|
|
content_uri: result.contentUri,
|
|
});
|
|
} catch (e) {
|
|
console.error("error while uploading a file", e);
|
|
this.handleDriverError(e, request, "Unexpected error while uploading a file");
|
|
}
|
|
}
|
|
|
|
private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.downloadFile(request.data.content_uri);
|
|
|
|
return this.transport.reply<IDownloadFileActionFromWidgetResponseData>(request, { file: result.file });
|
|
} catch (e) {
|
|
console.error("error while downloading a file", e);
|
|
this.handleDriverError(e, request, "Unexpected error while downloading a file");
|
|
}
|
|
}
|
|
|
|
private async handleRtcLivekitGetToken(request: IRtcLivekitGetTokenFromWidgetActionRequest): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4533RtcLivekitGetToken)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.getRtcLivekitToken(request.data);
|
|
|
|
return this.transport.reply<IRtcLivekitGetTokenFromWidgetResponseData>(request, result);
|
|
} catch (e) {
|
|
console.error("error while getting a LiveKit token", e);
|
|
this.handleDriverError(e, request, "Unexpected error while getting a LiveKit token");
|
|
}
|
|
}
|
|
|
|
private async handleRtcLivekitDelegateDelayedLeave(
|
|
request: IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest,
|
|
): Promise<void> {
|
|
if (!this.hasCapability(MatrixCapabilities.MSC4533RtcLivekitDelegateDelayedLeave)) {
|
|
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: { message: "Missing capability" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await this.driver.delegateRtcLivekitDelayedLeave(request.data);
|
|
|
|
return this.transport.reply<IRtcLivekitDelegateDelayedLeaveFromWidgetResponseData>(request, result);
|
|
} catch (e) {
|
|
console.error("error while delegating a LiveKit delayed leave", e);
|
|
this.handleDriverError(e, request, "Unexpected error while delegating a LiveKit delayed leave");
|
|
}
|
|
}
|
|
|
|
private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string): void {
|
|
const data = this.driver.processError(e);
|
|
this.transport.reply<IWidgetApiErrorResponseData>(request, {
|
|
error: {
|
|
message,
|
|
...data,
|
|
},
|
|
});
|
|
}
|
|
|
|
private handleMessage(ev: CustomEvent<IWidgetApiRequest>): void | Promise<void> {
|
|
if (this.isStopped) return;
|
|
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
|
|
detail: ev.detail,
|
|
cancelable: true,
|
|
});
|
|
this.emit(`action:${ev.detail.action}`, actionEv);
|
|
if (!actionEv.defaultPrevented) {
|
|
switch (ev.detail.action) {
|
|
case WidgetApiFromWidgetAction.ContentLoaded:
|
|
return this.handleContentLoadedAction(<IContentLoadedActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.SupportedApiVersions:
|
|
return this.replyVersions(<ISupportedVersionsActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.SendEvent:
|
|
return this.handleSendEvent(<ISendEventFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.SendToDevice:
|
|
return this.handleSendToDevice(<ISendToDeviceFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.GetOpenIDCredentials:
|
|
return this.handleOIDC(<IGetOpenIDActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC2931Navigate:
|
|
return this.handleNavigate(<INavigateActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities:
|
|
return this.handleCapabilitiesRenegotiate(<IRenegotiateCapabilitiesActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC2876ReadEvents:
|
|
return this.handleReadEvents(<IReadEventFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.WatchTurnServers:
|
|
return this.handleWatchTurnServers(<IWatchTurnServersRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.UnwatchTurnServers:
|
|
return this.handleUnwatchTurnServers(<IUnwatchTurnServersRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC3869ReadRelations:
|
|
return this.handleReadRelations(<IReadRelationsFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC3973UserDirectorySearch:
|
|
return this.handleUserDirectorySearch(<IUserDirectorySearchFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.BeeperReadRoomAccountData:
|
|
return this.handleReadRoomAccountData(<IReadRoomAccountDataFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction:
|
|
return this.handleGetMediaConfig(<IGetMediaConfigActionFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4515GetRtcTransports:
|
|
return this.handleRtcTransports(<IRtcTransportsFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4039UploadFileAction:
|
|
return this.handleUploadFile(<IUploadFileActionFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4039DownloadFileAction:
|
|
return this.handleDownloadFile(<IDownloadFileActionFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent:
|
|
return this.handleUpdateDelayedEvent(<IUpdateDelayedEventFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4533RtcLivekitGetToken:
|
|
return this.handleRtcLivekitGetToken(<IRtcLivekitGetTokenFromWidgetActionRequest>ev.detail);
|
|
case WidgetApiFromWidgetAction.MSC4533RtcLivekitDelegateDelayedLeave:
|
|
return this.handleRtcLivekitDelegateDelayedLeave(
|
|
<IRtcLivekitDelegateDelayedLeaveFromWidgetActionRequest>ev.detail,
|
|
);
|
|
|
|
default:
|
|
return this.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
|
|
error: {
|
|
message: "Unknown or unsupported from-widget action: " + ev.detail.action,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Informs the widget that the client's theme has changed.
|
|
* @param theme The theme data, as an object with arbitrary contents.
|
|
*/
|
|
public updateTheme(theme: IThemeChangeActionRequestData): Promise<IWidgetApiResponseData> {
|
|
return this.transport.send(WidgetApiToWidgetAction.ThemeChange, theme);
|
|
}
|
|
|
|
/**
|
|
* Informs the widget that the client's language has changed.
|
|
* @param lang The BCP 47 identifier representing the client's current language.
|
|
*/
|
|
public updateLanguage(lang: string): Promise<IWidgetApiResponseData> {
|
|
return this.transport.send(WidgetApiToWidgetAction.LanguageChange, { lang });
|
|
}
|
|
|
|
/**
|
|
* Takes a screenshot of the widget.
|
|
* @returns Resolves to the widget's screenshot.
|
|
* @throws Throws if there is a problem.
|
|
*/
|
|
public takeScreenshot(): Promise<IScreenshotActionResponseData> {
|
|
return this.transport.send(WidgetApiToWidgetAction.TakeScreenshot, <IWidgetApiRequestEmptyData>{});
|
|
}
|
|
|
|
/**
|
|
* Alerts the widget to whether or not it is currently visible.
|
|
* @param {boolean} isVisible Whether the widget is visible or not.
|
|
* @returns {Promise<IWidgetApiResponseData>} Resolves when the widget acknowledges the update.
|
|
*/
|
|
public updateVisibility(isVisible: boolean): Promise<IWidgetApiResponseData> {
|
|
return this.transport.send(WidgetApiToWidgetAction.UpdateVisibility, <IVisibilityActionRequestData>{
|
|
visible: isVisible,
|
|
});
|
|
}
|
|
|
|
public sendWidgetConfig(data: IModalWidgetOpenRequestData): Promise<void> {
|
|
return this.transport.send<IModalWidgetOpenRequestData>(WidgetApiToWidgetAction.WidgetConfig, data).then();
|
|
}
|
|
|
|
public notifyModalWidgetButtonClicked(id: IModalWidgetOpenRequestDataButton["id"]): Promise<void> {
|
|
return this.transport
|
|
.send<IModalWidgetButtonClickedRequestData>(WidgetApiToWidgetAction.ButtonClicked, { id })
|
|
.then();
|
|
}
|
|
|
|
public notifyModalWidgetClose(data: IModalWidgetReturnData): Promise<void> {
|
|
return this.transport.send<IModalWidgetReturnData>(WidgetApiToWidgetAction.CloseModalWidget, data).then();
|
|
}
|
|
|
|
/**
|
|
* Feeds an event to the widget. As a client you are expected to call this
|
|
* for every new event in every room to which you are joined or invited.
|
|
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
|
|
* @param {string} currentViewedRoomId The room ID the user is currently
|
|
* interacting with. Not the room ID of the event.
|
|
* @returns {Promise<void>} Resolves when delivered or if the widget is not
|
|
* able to read the event due to permissions, rejects if the widget failed
|
|
* to handle the event.
|
|
* @deprecated It is recommended to communicate the viewed room ID by calling
|
|
* {@link ClientWidgetApi.setViewedRoomId} rather than passing it to this
|
|
* method.
|
|
*/
|
|
public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise<void>;
|
|
/**
|
|
* Feeds an event to the widget. As a client you are expected to call this
|
|
* for every new event (including state events) in every room to which you are joined or invited.
|
|
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
|
|
* @returns {Promise<void>} Resolves when delivered or if the widget is not
|
|
* able to read the event due to permissions, rejects if the widget failed
|
|
* to handle the event.
|
|
*/
|
|
public async feedEvent(rawEvent: IRoomEvent): Promise<void>;
|
|
public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId?: string): Promise<void> {
|
|
if (currentViewedRoomId !== undefined) this.setViewedRoomId(currentViewedRoomId);
|
|
if (rawEvent.room_id !== this.viewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) {
|
|
return; // no-op
|
|
}
|
|
|
|
if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) {
|
|
// state event
|
|
if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) {
|
|
return; // no-op
|
|
}
|
|
} else {
|
|
// message event
|
|
if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content as { msgtype?: string })?.["msgtype"])) {
|
|
return; // no-op
|
|
}
|
|
}
|
|
|
|
// Feed the event into the widget
|
|
await this.transport.send<ISendEventToWidgetRequestData>(
|
|
WidgetApiToWidgetAction.SendEvent,
|
|
// it's compatible, but missing the index signature
|
|
rawEvent as ISendEventToWidgetRequestData,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Feeds a to-device event to the widget. As a client you are expected to
|
|
* call this for every to-device event you receive.
|
|
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
|
|
* @param {boolean} encrypted Whether the event contents were encrypted.
|
|
* @returns {Promise<void>} Resolves when delivered or if the widget is not
|
|
* able to receive the event due to permissions, rejects if the widget
|
|
* failed to handle the event.
|
|
*/
|
|
public async feedToDevice(message: IToDeviceMessage, encrypted: boolean): Promise<void> {
|
|
if (this.canReceiveToDeviceEvent(message.type)) {
|
|
await this.transport.send<ISendToDeviceToWidgetRequestData>(WidgetApiToWidgetAction.SendToDevice, {
|
|
...message,
|
|
encrypted,
|
|
});
|
|
}
|
|
}
|
|
|
|
private viewedRoomId: string | null = null;
|
|
|
|
/**
|
|
* Indicate that a room is being viewed (making it possible for the widget
|
|
* to interact with it).
|
|
*/
|
|
public setViewedRoomId(roomId: string | null): void {
|
|
this.viewedRoomId = roomId;
|
|
// If the widget doesn't have timeline permissions for the room then
|
|
// this is its opportunity to learn the room state. We push the entire
|
|
// room state, which could be redundant if this room had been viewed
|
|
// once before, but it's easier than selectively pushing just the bits
|
|
// of state that changed while the room was in the background.
|
|
if (roomId !== null && !this.canUseRoomTimeline(roomId)) this.pushRoomState(roomId);
|
|
}
|
|
|
|
private async flushRoomState(): Promise<void> {
|
|
try {
|
|
// Only send a single action once all concurrent tasks have completed
|
|
do await Promise.all(this.pushRoomStateTasks);
|
|
while (this.pushRoomStateTasks.size > 0);
|
|
|
|
const events: IRoomEvent[] = [];
|
|
for (const eventTypeMap of this.pushRoomStateResult.values()) {
|
|
for (const stateKeyMap of eventTypeMap.values()) {
|
|
events.push(...stateKeyMap.values());
|
|
}
|
|
}
|
|
if ((await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE)) {
|
|
// Only send state updates when using UpdateState. Otherwise the SendEvent action will be responsible for state updates.
|
|
await this.transport.send<IUpdateStateToWidgetRequestData>(WidgetApiToWidgetAction.UpdateState, {
|
|
state: events,
|
|
});
|
|
}
|
|
} finally {
|
|
this.flushRoomStateTask = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the current sticky state of the room and pushes it to the widget.
|
|
*
|
|
* It will only push events that the widget is allowed to receive.
|
|
* @param roomId
|
|
* @private
|
|
*/
|
|
private async pushStickyState(roomId: string): Promise<void> {
|
|
console.debug("Pushing sticky state to widget for room", roomId);
|
|
return this.driver
|
|
.readStickyEvents(roomId)
|
|
.then((events) => {
|
|
// filter to the allowed sticky events
|
|
const filtered = events.filter((e) => {
|
|
return this.canReceiveRoomEvent(
|
|
e.type,
|
|
typeof e.content?.msgtype === "string" ? e.content.msgtype : null,
|
|
);
|
|
});
|
|
return { roomId, stickyEvents: filtered };
|
|
})
|
|
.then(async ({ roomId, stickyEvents }) => {
|
|
console.debug("Pushing", stickyEvents.length, "sticky events to widget for room", roomId);
|
|
const promises = stickyEvents.map((rawEvent) => {
|
|
return this.transport.send<ISendEventToWidgetRequestData>(
|
|
WidgetApiToWidgetAction.SendEvent,
|
|
// copied from feedEvent; it's compatible, but missing the index signature
|
|
rawEvent as ISendEventToWidgetRequestData,
|
|
);
|
|
});
|
|
await Promise.all(promises);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read the room's state and push all entries that the widget is allowed to
|
|
* read through to the widget.
|
|
*/
|
|
private pushRoomState(roomId: string): void {
|
|
for (const cap of this.allowedEvents) {
|
|
if (cap.kind === EventKind.State && cap.direction === EventDirection.Receive) {
|
|
// Initiate the task
|
|
const events = this.driver.readRoomState(roomId, cap.eventType, cap.keyStr ?? undefined);
|
|
const task = events
|
|
.then(
|
|
(events) => {
|
|
// When complete, queue the resulting events to be
|
|
// pushed to the widget
|
|
for (const event of events) {
|
|
let eventTypeMap = this.pushRoomStateResult.get(roomId);
|
|
if (eventTypeMap === undefined) {
|
|
eventTypeMap = new Map();
|
|
this.pushRoomStateResult.set(roomId, eventTypeMap);
|
|
}
|
|
let stateKeyMap = eventTypeMap.get(cap.eventType);
|
|
if (stateKeyMap === undefined) {
|
|
stateKeyMap = new Map();
|
|
eventTypeMap.set(cap.eventType, stateKeyMap);
|
|
}
|
|
if (!stateKeyMap.has(event.state_key!)) stateKeyMap.set(event.state_key!, event);
|
|
}
|
|
},
|
|
(e) =>
|
|
console.error(
|
|
`Failed to read room state for ${roomId} (${cap.eventType}, ${cap.keyStr})`,
|
|
e,
|
|
),
|
|
)
|
|
.then(() => {
|
|
// Mark request as no longer pending
|
|
this.pushRoomStateTasks.delete(task);
|
|
});
|
|
|
|
// Mark task as pending
|
|
this.pushRoomStateTasks.add(task);
|
|
// Assuming no other tasks are already happening concurrently,
|
|
// schedule the widget action that actually pushes the events
|
|
this.flushRoomStateTask ??= this.flushRoomState();
|
|
this.flushRoomStateTask.catch((e) => console.error("Failed to push room state", e));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Feeds a room state update to the widget. As a client you are expected to
|
|
* call this for every state update in every room to which you are joined or
|
|
* invited.
|
|
* @param {IRoomEvent} rawEvent The state event corresponding to the updated
|
|
* room state entry.
|
|
* @returns {Promise<void>} Resolves when delivered or if the widget is not
|
|
* able to receive the room state due to permissions, rejects if the
|
|
* widget failed to handle the update.
|
|
*/
|
|
public async feedStateUpdate(rawEvent: IRoomEvent): Promise<void> {
|
|
if (rawEvent.state_key === undefined) throw new Error("Not a state event");
|
|
if (
|
|
(rawEvent.room_id === this.viewedRoomId || this.canUseRoomTimeline(rawEvent.room_id)) &&
|
|
this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)
|
|
) {
|
|
// Updates could race with the initial push of the room's state
|
|
if (this.pushRoomStateTasks.size === 0) {
|
|
// No initial push tasks are pending; safe to send immediately
|
|
if ((await this.getWidgetVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE)) {
|
|
// Only send state updates when using UpdateState. Otherwise the SendEvent action will be responsible for state updates.
|
|
await this.transport.send<IUpdateStateToWidgetRequestData>(WidgetApiToWidgetAction.UpdateState, {
|
|
state: [rawEvent],
|
|
});
|
|
}
|
|
} else {
|
|
// Lump the update in with whatever data will be sent in the
|
|
// initial push later. Even if we set it to an "outdated" entry
|
|
// here, we can count on any newer entries being passed to this
|
|
// same method eventually; this won't cause stuck state.
|
|
let eventTypeMap = this.pushRoomStateResult.get(rawEvent.room_id);
|
|
if (eventTypeMap === undefined) {
|
|
eventTypeMap = new Map();
|
|
this.pushRoomStateResult.set(rawEvent.room_id, eventTypeMap);
|
|
}
|
|
let stateKeyMap = eventTypeMap.get(rawEvent.type);
|
|
if (stateKeyMap === undefined) {
|
|
stateKeyMap = new Map();
|
|
eventTypeMap.set(rawEvent.type, stateKeyMap);
|
|
}
|
|
if (!stateKeyMap.has(rawEvent.type)) stateKeyMap.set(rawEvent.state_key, rawEvent);
|
|
do await Promise.all(this.pushRoomStateTasks);
|
|
while (this.pushRoomStateTasks.size > 0);
|
|
await this.flushRoomStateTask;
|
|
}
|
|
}
|
|
}
|
|
}
|