diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 9005723..f3318f5 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -24,7 +24,7 @@ import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; import { Capability } from "./interfaces/Capabilities"; -import { WidgetDriver } from "./driver/WidgetDriver"; +import { ISendEventDetails, WidgetDriver } from "./driver/WidgetDriver"; import { ICapabilitiesActionResponseData } from "./interfaces/CapabilitiesAction"; import { ISupportedVersionsActionRequest, @@ -40,6 +40,13 @@ import { IModalWidgetOpenRequestDataButton, IModalWidgetReturnData, } from "./interfaces/ModalWidgetActions"; +import { + ISendEventFromWidgetActionRequest, + ISendEventFromWidgetResponseData, + ISendEventToWidgetRequestData, +} from "./interfaces/SendEventAction"; +import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; +import { IRoomEvent } from "./interfaces/IRoomEvent"; /** * API handler for the client side of widgets. This raises events @@ -70,6 +77,7 @@ export class ClientWidgetApi extends EventEmitter { private capabilitiesFinished = false; private allowedCapabilities = new Set(); + private allowedEvents: WidgetEventCapability[] = []; private isStopped = false; /** @@ -115,6 +123,26 @@ export class ClientWidgetApi extends EventEmitter { return this.allowedCapabilities.has(capability); } + public canSendRoomEvent(eventType: string, msgtype: string = null): boolean { + return this.allowedEvents.some(e => + e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Send); + } + + public canSendStateEvent(eventType: string, stateKey: string): boolean { + return this.allowedEvents.some(e => + e.matchesAsStateEvent(eventType, stateKey) && e.direction === EventDirection.Send); + } + + public canReceiveRoomEvent(eventType: string, msgtype: string = null): boolean { + return this.allowedEvents.some(e => + e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Receive); + } + + public canReceiveStateEvent(eventType: string, stateKey: string): boolean { + return this.allowedEvents.some(e => + e.matchesAsStateEvent(eventType, stateKey) && e.direction === EventDirection.Receive); + } + public stop() { this.isStopped = true; this.transport.stop(); @@ -140,7 +168,9 @@ export class ClientWidgetApi extends EventEmitter { ).then(caps => { return this.driver.validateCapabilities(new Set(caps.capabilities)); }).then(allowedCaps => { + console.log(`Widget ${this.widget.id} is allowed capabilities:`, Array.from(allowedCaps)); this.allowedCapabilities = allowedCaps; + this.allowedEvents = Array.from(new Set(WidgetEventCapability.findEventCapabilities(allowedCaps))); this.capabilitiesFinished = true; this.emit("ready"); }); @@ -165,6 +195,63 @@ export class ClientWidgetApi extends EventEmitter { }); } + private async handleSendEvent(request: ISendEventFromWidgetActionRequest) { + if (!request.data.type) { + return this.transport.reply(request, { + error: {message: "Invalid request - missing event type"}, + }); + } + + const isState = request.data.state_key !== null && request.data.state_key !== undefined; + let sentEvent: ISendEventDetails; + if (isState) { + if (!this.canSendStateEvent(request.data.type, request.data.state_key)) { + return this.transport.reply(request, { + error: {message: "Cannot send state events of this type"}, + }); + } + + try { + sentEvent = await this.driver.sendEvent( + request.data.type, + request.data.content || {}, + request.data.state_key, + ); + } catch (e) { + console.error("error sending event: ", e); + return this.transport.reply(request, { + error: {message: "Error sending event"}, + }); + } + } else { + const content = request.data.content || {}; + const msgtype = content['msgtype']; + if (!this.canSendRoomEvent(request.data.type, msgtype)) { + return this.transport.reply(request, { + error: {message: "Cannot send room events of this type"}, + }); + } + + try { + sentEvent = await this.driver.sendEvent( + request.data.type, + content, + null, // not sending a state event + ); + } catch (e) { + console.error("error sending event: ", e); + return this.transport.reply(request, { + error: {message: "Error sending event"}, + }); + } + } + + return this.transport.reply(request, { + room_id: sentEvent.roomId, + event_id: sentEvent.eventId, + }); + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { @@ -178,6 +265,8 @@ export class ClientWidgetApi extends EventEmitter { return this.handleContentLoadedAction(ev.detail); case WidgetApiFromWidgetAction.SupportedApiVersions: return this.replyVersions(ev.detail); + case WidgetApiFromWidgetAction.SendEvent: + return this.handleSendEvent(ev.detail); default: return this.transport.reply(ev.detail, { error: { @@ -223,4 +312,31 @@ export class ClientWidgetApi extends EventEmitter { WidgetApiToWidgetAction.CloseModalWidget, data, ).then(); } + + /** + * Feeds an event to the widget. If the widget is not able to accept the event due to + * permissions, this will no-op and return calmly. If the widget failed to handle the + * event, this will raise an error. + * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. + * @returns {Promise} Resolves when complete, rejects if there was an error sending. + */ + public feedEvent(rawEvent: IRoomEvent): Promise { + if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) { + // state event + if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) { + return Promise.resolve(); // no-op + } + } else { + // message event + if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content || {})['msgtype'])) { + return Promise.resolve(); // no-op + } + } + + // Feed the event into the widget + return this.transport.send( + WidgetApiToWidgetAction.SendEvent, + rawEvent as ISendEventToWidgetRequestData, // it's compatible, but missing the index signature + ).then(); + } } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index a804ab7..1455f46 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -44,6 +44,7 @@ import { IModalWidgetOpenRequestDataButton, IModalWidgetReturnData, } from "./interfaces/ModalWidgetActions"; +import { ISendEventFromWidgetRequestData, ISendEventFromWidgetResponseData } from "./interfaces/SendEventAction"; /** * API handler for widgets. This raises events for each action @@ -203,6 +204,24 @@ export class WidgetApi extends EventEmitter { return this.transport.send(WidgetApiFromWidgetAction.CloseModalWidget, data).then(); } + public sendRoomEvent(eventType: string, content: unknown): Promise { + return this.transport.send( + WidgetApiFromWidgetAction.SendEvent, + {type: eventType, content}, + ); + } + + public sendStateEvent( + eventType: string, + stateKey: string, + content: unknown, + ): Promise { + return this.transport.send( + WidgetApiFromWidgetAction.SendEvent, + {type: eventType, content, state_key: stateKey}, + ); + } + /** * Starts the communication channel. This should be done early to ensure * that messages are not missed. Communication can only be stopped by the client. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index d623e8d..52782ab 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -16,6 +16,11 @@ import { Capability } from ".."; +export interface ISendEventDetails { + roomId: string; + eventId: string; +} + /** * Represents the functions and behaviour the widget-api is unable to * do, such as prompting the user for information or interacting with @@ -41,4 +46,19 @@ export abstract class WidgetDriver { public validateCapabilities(requested: Set): Promise> { return Promise.resolve(new Set()); } + + /** + * Sends an event into the room the user is currently looking at. The widget API + * will have already verified that the widget is capable of sending the event. + * @param {string} eventType The event type to be sent. + * @param {*} content The content for the event. + * @param {string|null} stateKey The state key if this is a state event, otherwise null. + * May be an empty string. + * @returns {Promise} Resolves when the event has been sent with + * details of that event. + * @throws Rejected when the event could not be sent. + */ + public sendEvent(eventType: string, content: unknown, stateKey: string = null): Promise { + return Promise.reject(new Error("Failed to override function")); + } } diff --git a/src/index.ts b/src/index.ts index 932a132..1ea46fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,8 +48,11 @@ export * from "./interfaces/WidgetKind"; export * from "./interfaces/ModalButtonKind"; export * from "./interfaces/ModalWidgetActions"; export * from "./interfaces/WidgetConfigAction"; +export * from "./interfaces/SendEventAction"; +export * from "./interfaces/IRoomEvent"; // Complex models +export * from "./models/WidgetEventCapability"; export * from "./models/validation/url"; export * from "./models/validation/utils"; export * from "./models/Widget"; diff --git a/src/interfaces/ApiVersion.ts b/src/interfaces/ApiVersion.ts index 0c5c05d..57b6644 100644 --- a/src/interfaces/ApiVersion.ts +++ b/src/interfaces/ApiVersion.ts @@ -20,10 +20,15 @@ export enum MatrixApiVersion { V010 = "0.1.0", // first release } -export type ApiVersion = MatrixApiVersion | string; +export enum UnstableApiVersion { + MSC2762 = "org.matrix.msc2762", +} + +export type ApiVersion = MatrixApiVersion | UnstableApiVersion | string; export const CurrentApiVersions: ApiVersion[] = [ MatrixApiVersion.Prerelease1, MatrixApiVersion.Prerelease2, MatrixApiVersion.V010, + UnstableApiVersion.MSC2762, ]; diff --git a/src/interfaces/IRoomEvent.ts b/src/interfaces/IRoomEvent.ts new file mode 100644 index 0000000..2504f6f --- /dev/null +++ b/src/interfaces/IRoomEvent.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 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. + */ + +export interface IRoomEvent { + type: string; + sender: string; + event_id: string; // eslint-disable-line camelcase + state_key?: string; // eslint-disable-line camelcase + origin_server_ts: number; // eslint-disable-line camelcase + content: unknown; + unsigned: unknown; +} diff --git a/src/interfaces/SendEventAction.ts b/src/interfaces/SendEventAction.ts new file mode 100644 index 0000000..9e926cb --- /dev/null +++ b/src/interfaces/SendEventAction.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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 { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; +import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; +import { IWidgetApiResponseData } from "./IWidgetApiResponse"; +import { IRoomEvent } from "./IRoomEvent"; + +export interface ISendEventFromWidgetRequestData extends IWidgetApiRequestData { + state_key?: string; // eslint-disable-line camelcase + type: string; + content: unknown; +} + +export interface ISendEventFromWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.SendEvent; + data: ISendEventFromWidgetRequestData; +} + +export interface ISendEventFromWidgetResponseData extends IWidgetApiResponseData { + room_id: string; // eslint-disable-line camelcase + event_id: string; // eslint-disable-line camelcase +} + +export interface ISendEventFromWidgetActionResponse extends ISendEventFromWidgetActionRequest { + response: ISendEventFromWidgetResponseData; +} + +export interface ISendEventToWidgetRequestData extends IWidgetApiRequestData, IRoomEvent { +} + +export interface ISendEventToWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiToWidgetAction.SendEvent; + data: ISendEventToWidgetRequestData; +} + +export interface ISendEventToWidgetResponseData extends IWidgetApiResponseData { + // nothing +} + +export interface ISendEventToWidgetActionResponse extends ISendEventToWidgetActionRequest { + response: ISendEventToWidgetResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index ff597f0..0692f7c 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -23,6 +23,7 @@ export enum WidgetApiToWidgetAction { WidgetConfig = "widget_config", CloseModalWidget = "close_modal", ButtonClicked = "button_clicked", + SendEvent = "send_event", } export enum WidgetApiFromWidgetAction { @@ -33,6 +34,7 @@ export enum WidgetApiFromWidgetAction { GetOpenIDCredentials = "get_openid", CloseModalWidget = "close_modal", OpenModalWidget = "open_modal", + SendEvent = "send_event", } export type WidgetApiAction = WidgetApiToWidgetAction | WidgetApiFromWidgetAction | string; diff --git a/src/models/WidgetEventCapability.ts b/src/models/WidgetEventCapability.ts new file mode 100644 index 0000000..8dfe6dd --- /dev/null +++ b/src/models/WidgetEventCapability.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 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 { Capability } from ".."; + +export enum EventDirection { + Send, + Receive, +} + +export class WidgetEventCapability { + private constructor( + public readonly direction: EventDirection, + public readonly eventType: string, + public readonly isState: boolean, + public readonly keyStr: string | null, + public readonly raw: string, + ) { + } + + public matchesAsStateEvent(eventType: string, stateKey: string): boolean { + if (!this.isState) return false; // looking for state, not state + if (this.eventType !== eventType) return false; // event type mismatch + if (this.keyStr === null) return true; // all state keys are allowed + if (this.keyStr === stateKey) return true; // this state key is allowed + + // Default not allowed + return false; + } + + public matchesAsRoomEvent(eventType: string, msgtype: string = null): boolean { + if (this.isState) return false; // looking for not-state, is state + if (this.eventType !== eventType) return false; // event type mismatch + + if (this.eventType === "m.room.message") { + if (this.keyStr === null) return true; // all message types are allowed + if (this.keyStr === msgtype) return true; // this message type is allowed + } else { + return true; // already passed the check for if the event is allowed + } + + // Default not allowed + return false; + } + + /** + * Parses a capabilities request to find all the event capability requests. + * @param {Iterable} capabilities The capabilities requested/to parse. + * @returns {WidgetEventCapability[]} An array of event capability requests. May be empty, but never null. + */ + public static findEventCapabilities(capabilities: Iterable): WidgetEventCapability[] { + const parsed: WidgetEventCapability[] = []; + for (const cap of capabilities) { + let direction: EventDirection = null; + let eventSegment: string; + let isState = false; + + // TODO: Enable support for m.* namespace once the MSC lands. + + if (cap.startsWith("org.matrix.msc2762.send.")) { + if (cap.startsWith("org.matrix.msc2762.send.event:")) { + direction = EventDirection.Send; + eventSegment = cap.substring("org.matrix.msc2762.send.event:".length); + } else if (cap.startsWith("org.matrix.msc2762.send.state_event:")) { + direction = EventDirection.Send; + isState = true; + eventSegment = cap.substring("org.matrix.msc2762.send.state_event:".length); + } + } else if (cap.startsWith("org.matrix.msc2762.receive.")) { + if (cap.startsWith("org.matrix.msc2762.receive.event:")) { + direction = EventDirection.Receive; + eventSegment = cap.substring("org.matrix.msc2762.receive.event:".length); + } else if (cap.startsWith("org.matrix.msc2762.receive.state_event:")) { + direction = EventDirection.Receive; + isState = true; + eventSegment = cap.substring("org.matrix.msc2762.receive.state_event:".length); + } + } + + if (direction === null) continue; + + let keyStr: string = null; + if (eventSegment.includes('#')) { + const p = eventSegment.split('#'); + eventSegment = p[0]; + keyStr = p.slice(1).join('#'); + } + + parsed.push(new WidgetEventCapability(direction, eventSegment, isState, keyStr, cap)); + } + return parsed; + } +}