diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 6a246c2..b96a071 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -49,6 +49,11 @@ import { ISendEventFromWidgetResponseData, ISendEventToWidgetRequestData, } from "./interfaces/SendEventAction"; +import { + ISendToDeviceFromWidgetActionRequest, + ISendToDeviceFromWidgetResponseData, + ISendToDeviceToWidgetRequestData, +} from "./interfaces/SendToDeviceAction"; import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; import { IRoomEvent } from "./interfaces/IRoomEvent"; import { @@ -143,23 +148,27 @@ export class ClientWidgetApi extends EventEmitter { } public canSendRoomEvent(eventType: string, msgtype: string = null): boolean { - return this.allowedEvents.some(e => - e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Send); + 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(eventType, stateKey) && e.direction === EventDirection.Send); + 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): boolean { - return this.allowedEvents.some(e => - e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Receive); + return this.allowedEvents.some(e => e.matchesAsRoomEvent(EventDirection.Receive, eventType, msgtype)); } public canReceiveStateEvent(eventType: string, stateKey: string): boolean { - return this.allowedEvents.some(e => - e.matchesAsStateEvent(eventType, stateKey) && e.direction === EventDirection.Receive); + 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 stop() { @@ -453,6 +462,32 @@ export class ClientWidgetApi extends EventEmitter { }); } + private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise { + if (!request.data.type) { + await this.transport.reply(request, { + error: {message: "Invalid request - missing event type"}, + }); + } else if (!request.data.messages) { + await this.transport.reply(request, { + error: {message: "Invalid request - missing event contents"}, + }); + } else if (!this.canSendToDeviceEvent(request.data.type)) { + await this.transport.reply(request, { + error: {message: "Cannot send to-device events of this type"}, + }); + } else { + try { + await this.driver.sendToDevice(request.data.type, request.data.messages); + await this.transport.reply(request, {}); + } catch (e) { + console.error("error sending to-device event", e); + await this.transport.reply(request, { + error: {message: "Error sending event"}, + }); + } + } + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { @@ -468,6 +503,8 @@ export class ClientWidgetApi extends EventEmitter { return this.replyVersions(ev.detail); case WidgetApiFromWidgetAction.SendEvent: return this.handleSendEvent(ev.detail); + case WidgetApiFromWidgetAction.SendToDevice: + return this.handleSendToDevice(ev.detail); case WidgetApiFromWidgetAction.GetOpenIDCredentials: return this.handleOIDC(ev.detail); case WidgetApiFromWidgetAction.MSC2931Navigate: @@ -531,27 +568,43 @@ export class ClientWidgetApi extends EventEmitter { * Not the room ID of the event. * @returns {Promise} Resolves when complete, rejects if there was an error sending. */ - public feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise { + public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise { if (rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) { - return Promise.resolve(); // no-op + return; // no-op } if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) { // state event if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) { - return Promise.resolve(); // no-op + return; // no-op } } else { // message event - if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content || {})['msgtype'])) { - return Promise.resolve(); // no-op + if (!this.canReceiveRoomEvent(rawEvent.type, rawEvent.content?.["msgtype"])) { + return; // no-op } } // Feed the event into the widget - return this.transport.send( + await this.transport.send( WidgetApiToWidgetAction.SendEvent, rawEvent as ISendEventToWidgetRequestData, // it's compatible, but missing the index signature - ).then(); + ); + } + + /** + * Feeds a to-device 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 async feedToDevice(rawEvent: IRoomEvent): Promise { + if (this.canReceiveToDeviceEvent(rawEvent.type)) { + await this.transport.send( + WidgetApiToWidgetAction.SendToDevice, + rawEvent as ISendToDeviceToWidgetRequestData, // it's compatible, but missing the index signature + ); + } } } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 14f03d3..8f0c767 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -53,6 +53,10 @@ import { } from "./interfaces/ModalWidgetActions"; import { ISetModalButtonEnabledActionRequestData } from "./interfaces/SetModalButtonEnabledAction"; import { ISendEventFromWidgetRequestData, ISendEventFromWidgetResponseData } from "./interfaces/SendEventAction"; +import { + ISendToDeviceFromWidgetRequestData, + ISendToDeviceFromWidgetResponseData, +} from "./interfaces/SendToDeviceAction"; import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; import { INavigateActionRequestData } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetRequestData, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; @@ -179,6 +183,26 @@ export class WidgetApi extends EventEmitter { this.requestCapability(WidgetEventCapability.forStateEvent(EventDirection.Receive, eventType, stateKey).raw); } + /** + * Requests the capability to send a given to-device event. It is not + * guaranteed to be allowed, but will be asked for if the negotiation has + * not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToSendToDevice(eventType: string) { + this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw); + } + + /** + * Requests the capability to receive a given to-device event. It is not + * guaranteed to be allowed, but will be asked for if the negotiation has + * not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToReceiveToDevice(eventType: string) { + this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw); + } + /** * Requests the capability to send a given room event. It is not guaranteed to be * allowed, but will be asked for if the negotiation has not already happened. @@ -362,6 +386,16 @@ export class WidgetApi extends EventEmitter { ); } + public sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: unknown } }, + ): Promise { + return this.transport.send( + WidgetApiFromWidgetAction.SendToDevice, + {type: eventType, messages: contentMap}, + ); + } + public readRoomEvents( eventType: string, limit?: number, diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 5cd9518..27b0b12 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -75,6 +75,21 @@ export abstract class WidgetDriver { return Promise.reject(new Error("Failed to override function")); } + /** + * Sends a to-device event. 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 {Object} contentMap A map from user ID and device ID to event content. + * @returns {Promise} Resolves when the event has been sent. + * @throws Rejected when the event could not be sent. + */ + public sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: unknown } }, + ): Promise { + return Promise.reject(new Error("Failed to override function")); + } + /** * Reads all events of the given type, and optionally `msgtype` (if applicable/defined), * the user has access to. The widget API will have already verified that the widget is diff --git a/src/index.ts b/src/index.ts index c73a139..9f2bc2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export * from "./interfaces/ModalWidgetActions"; export * from "./interfaces/SetModalButtonEnabledAction"; export * from "./interfaces/WidgetConfigAction"; export * from "./interfaces/SendEventAction"; +export * from "./interfaces/SendToDeviceAction"; export * from "./interfaces/ReadEventAction"; export * from "./interfaces/IRoomEvent"; export * from "./interfaces/NavigateAction"; diff --git a/src/interfaces/SendToDeviceAction.ts b/src/interfaces/SendToDeviceAction.ts new file mode 100644 index 0000000..8e0eba7 --- /dev/null +++ b/src/interfaces/SendToDeviceAction.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2022 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 ISendToDeviceFromWidgetRequestData extends IWidgetApiRequestData { + type: string, + messages: { [userId: string]: { [deviceId: string]: unknown } }, +} + +export interface ISendToDeviceFromWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.SendToDevice; + data: ISendToDeviceFromWidgetRequestData; +} + +export interface ISendToDeviceFromWidgetResponseData extends IWidgetApiResponseData { + // nothing +} + +export interface ISendToDeviceFromWidgetActionResponse extends ISendToDeviceFromWidgetActionRequest { + response: ISendToDeviceFromWidgetResponseData; +} + +export interface ISendToDeviceToWidgetRequestData extends IWidgetApiRequestData, IRoomEvent { +} + +export interface ISendToDeviceToWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiToWidgetAction.SendToDevice; + data: ISendToDeviceToWidgetRequestData; +} + +export interface ISendToDeviceToWidgetResponseData extends IWidgetApiResponseData { + // nothing +} + +export interface ISendToDeviceToWidgetActionResponse extends ISendToDeviceToWidgetActionRequest { + response: ISendToDeviceToWidgetResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index 785e13b..a75a373 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -25,6 +25,7 @@ export enum WidgetApiToWidgetAction { CloseModalWidget = "close_modal", ButtonClicked = "button_clicked", SendEvent = "send_event", + SendToDevice = "send_to_device", } export enum WidgetApiFromWidgetAction { @@ -37,6 +38,7 @@ export enum WidgetApiFromWidgetAction { OpenModalWidget = "open_modal", SetModalButtonEnabled = "set_button_enabled", SendEvent = "send_event", + SendToDevice = "send_to_device", /** * @deprecated It is not recommended to rely on this existing - it can be removed without notice. diff --git a/src/models/WidgetEventCapability.ts b/src/models/WidgetEventCapability.ts index 51e7e1d..8f968e3 100644 --- a/src/models/WidgetEventCapability.ts +++ b/src/models/WidgetEventCapability.ts @@ -16,6 +16,12 @@ import { Capability } from ".."; +export enum EventKind { + Event = "event", + State = "state_event", + ToDevice = "to_device", +} + export enum EventDirection { Send = "send", Receive = "receive", @@ -25,14 +31,15 @@ export class WidgetEventCapability { private constructor( public readonly direction: EventDirection, public readonly eventType: string, - public readonly isState: boolean, + public readonly kind: EventKind, 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 + public matchesAsStateEvent(direction: EventDirection, eventType: string, stateKey: string): boolean { + if (this.kind !== EventKind.State) return false; // not a state event + if (this.direction !== direction) return false; // direction mismatch 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 @@ -41,8 +48,18 @@ export class WidgetEventCapability { return false; } - public matchesAsRoomEvent(eventType: string, msgtype: string = null): boolean { - if (this.isState) return false; // looking for not-state, is state + public matchesAsToDeviceEvent(direction: EventDirection, eventType: string): boolean { + if (this.kind !== EventKind.ToDevice) return false; // not a to-device event + if (this.direction !== direction) return false; // direction mismatch + if (this.eventType !== eventType) return false; // event type mismatch + + // Checks passed, the event is allowed + return true; + } + + public matchesAsRoomEvent(direction: EventDirection, eventType: string, msgtype: string = null): boolean { + if (this.kind !== EventKind.Event) return false; // not a room event + if (this.direction !== direction) return false; // direction mismatch if (this.eventType !== eventType) return false; // event type mismatch if (this.eventType === "m.room.message") { @@ -71,6 +88,15 @@ export class WidgetEventCapability { return WidgetEventCapability.findEventCapabilities([str])[0]; } + public static forToDeviceEvent(direction: EventDirection, eventType: string): WidgetEventCapability { + // TODO: Enable support for m.* namespace once the MSC lands. + // https://github.com/matrix-org/matrix-widget-api/issues/56 + const str = `org.matrix.msc3819.${direction}.to_device:${eventType}`; + + // cheat by sending it through the processor + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + public static forRoomEvent(direction: EventDirection, eventType: string): WidgetEventCapability { // TODO: Enable support for m.* namespace once the MSC lands. // https://github.com/matrix-org/matrix-widget-api/issues/22 @@ -100,38 +126,45 @@ export class WidgetEventCapability { for (const cap of capabilities) { let direction: EventDirection = null; let eventSegment: string; - let isState = false; + let kind: EventKind = null; - // TODO: Enable support for m.* namespace once the MSC lands. + // TODO: Enable support for m.* namespace once the MSCs land. // https://github.com/matrix-org/matrix-widget-api/issues/22 - - 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); - } + // https://github.com/matrix-org/matrix-widget-api/issues/56 + + if (cap.startsWith("org.matrix.msc2762.send.event:")) { + direction = EventDirection.Send; + kind = EventKind.Event; + eventSegment = cap.substring("org.matrix.msc2762.send.event:".length); + } else if (cap.startsWith("org.matrix.msc2762.send.state_event:")) { + direction = EventDirection.Send; + kind = EventKind.State; + eventSegment = cap.substring("org.matrix.msc2762.send.state_event:".length); + } else if (cap.startsWith("org.matrix.msc3819.send.to_device:")) { + direction = EventDirection.Send; + kind = EventKind.ToDevice; + eventSegment = cap.substring("org.matrix.msc3819.send.to_device:".length); + } else if (cap.startsWith("org.matrix.msc2762.receive.event:")) { + direction = EventDirection.Receive; + kind = EventKind.Event; + eventSegment = cap.substring("org.matrix.msc2762.receive.event:".length); + } else if (cap.startsWith("org.matrix.msc2762.receive.state_event:")) { + direction = EventDirection.Receive; + kind = EventKind.State; + eventSegment = cap.substring("org.matrix.msc2762.receive.state_event:".length); + } else if (cap.startsWith("org.matrix.msc3819.receive.to_device:")) { + direction = EventDirection.Receive; + kind = EventKind.ToDevice; + eventSegment = cap.substring("org.matrix.msc3819.receive.to_device:".length); } - if (direction === null) continue; + if (direction === null || kind === null) continue; // The capability uses `#` as a separator between event type and state key/msgtype, // so we split on that. However, a # is also valid in either one of those so we // join accordingly. // Eg: `m.room.message##m.text` is "m.room.message" event with msgtype "#m.text". - const expectingKeyStr = eventSegment.startsWith("m.room.message#") || isState; + const expectingKeyStr = eventSegment.startsWith("m.room.message#") || kind === EventKind.State; let keyStr: string = null; if (eventSegment.includes('#') && expectingKeyStr) { // Dev note: regex is difficult to write, so instead the rules are manually written @@ -164,7 +197,7 @@ export class WidgetEventCapability { keyStr = parts.slice(idx + 1).join('#'); } - parsed.push(new WidgetEventCapability(direction, eventSegment, isState, keyStr, cap)); + parsed.push(new WidgetEventCapability(direction, eventSegment, kind, keyStr, cap)); } return parsed; }