Skip to content

Commit f35e37a

Browse files
authored
Implement MSC3819: Allowing widgets to send/receive to-device messages (#57)
* Implement MSC3819: Allowing widgets to send/receive to-device messages * Fix typo
1 parent b57902f commit f35e37a

File tree

7 files changed

+236
-44
lines changed

7 files changed

+236
-44
lines changed

src/ClientWidgetApi.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ import {
4949
ISendEventFromWidgetResponseData,
5050
ISendEventToWidgetRequestData,
5151
} from "./interfaces/SendEventAction";
52+
import {
53+
ISendToDeviceFromWidgetActionRequest,
54+
ISendToDeviceFromWidgetResponseData,
55+
ISendToDeviceToWidgetRequestData,
56+
} from "./interfaces/SendToDeviceAction";
5257
import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability";
5358
import { IRoomEvent } from "./interfaces/IRoomEvent";
5459
import {
@@ -143,23 +148,27 @@ export class ClientWidgetApi extends EventEmitter {
143148
}
144149

145150
public canSendRoomEvent(eventType: string, msgtype: string = null): boolean {
146-
return this.allowedEvents.some(e =>
147-
e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Send);
151+
return this.allowedEvents.some(e => e.matchesAsRoomEvent(EventDirection.Send, eventType, msgtype));
148152
}
149153

150154
public canSendStateEvent(eventType: string, stateKey: string): boolean {
151-
return this.allowedEvents.some(e =>
152-
e.matchesAsStateEvent(eventType, stateKey) && e.direction === EventDirection.Send);
155+
return this.allowedEvents.some(e => e.matchesAsStateEvent(EventDirection.Send, eventType, stateKey));
156+
}
157+
158+
public canSendToDeviceEvent(eventType: string): boolean {
159+
return this.allowedEvents.some(e => e.matchesAsToDeviceEvent(EventDirection.Send, eventType));
153160
}
154161

155162
public canReceiveRoomEvent(eventType: string, msgtype: string = null): boolean {
156-
return this.allowedEvents.some(e =>
157-
e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Receive);
163+
return this.allowedEvents.some(e => e.matchesAsRoomEvent(EventDirection.Receive, eventType, msgtype));
158164
}
159165

160166
public canReceiveStateEvent(eventType: string, stateKey: string): boolean {
161-
return this.allowedEvents.some(e =>
162-
e.matchesAsStateEvent(eventType, stateKey) && e.direction === EventDirection.Receive);
167+
return this.allowedEvents.some(e => e.matchesAsStateEvent(EventDirection.Receive, eventType, stateKey));
168+
}
169+
170+
public canReceiveToDeviceEvent(eventType: string): boolean {
171+
return this.allowedEvents.some(e => e.matchesAsToDeviceEvent(EventDirection.Receive, eventType));
163172
}
164173

165174
public stop() {
@@ -453,6 +462,32 @@ export class ClientWidgetApi extends EventEmitter {
453462
});
454463
}
455464

465+
private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise<void> {
466+
if (!request.data.type) {
467+
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
468+
error: {message: "Invalid request - missing event type"},
469+
});
470+
} else if (!request.data.messages) {
471+
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
472+
error: {message: "Invalid request - missing event contents"},
473+
});
474+
} else if (!this.canSendToDeviceEvent(request.data.type)) {
475+
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
476+
error: {message: "Cannot send to-device events of this type"},
477+
});
478+
} else {
479+
try {
480+
await this.driver.sendToDevice(request.data.type, request.data.messages);
481+
await this.transport.reply<ISendToDeviceFromWidgetResponseData>(request, {});
482+
} catch (e) {
483+
console.error("error sending to-device event", e);
484+
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
485+
error: {message: "Error sending event"},
486+
});
487+
}
488+
}
489+
}
490+
456491
private handleMessage(ev: CustomEvent<IWidgetApiRequest>) {
457492
if (this.isStopped) return;
458493
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
@@ -468,6 +503,8 @@ export class ClientWidgetApi extends EventEmitter {
468503
return this.replyVersions(<ISupportedVersionsActionRequest>ev.detail);
469504
case WidgetApiFromWidgetAction.SendEvent:
470505
return this.handleSendEvent(<ISendEventFromWidgetActionRequest>ev.detail);
506+
case WidgetApiFromWidgetAction.SendToDevice:
507+
return this.handleSendToDevice(<ISendToDeviceFromWidgetActionRequest>ev.detail);
471508
case WidgetApiFromWidgetAction.GetOpenIDCredentials:
472509
return this.handleOIDC(<IGetOpenIDActionRequest>ev.detail);
473510
case WidgetApiFromWidgetAction.MSC2931Navigate:
@@ -531,27 +568,43 @@ export class ClientWidgetApi extends EventEmitter {
531568
* Not the room ID of the event.
532569
* @returns {Promise<void>} Resolves when complete, rejects if there was an error sending.
533570
*/
534-
public feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise<void> {
571+
public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise<void> {
535572
if (rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) {
536-
return Promise.resolve(); // no-op
573+
return; // no-op
537574
}
538575

539576
if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) {
540577
// state event
541578
if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) {
542-
return Promise.resolve(); // no-op
579+
return; // no-op
543580
}
544581
} else {
545582
// message event
546-
if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content || {})['msgtype'])) {
547-
return Promise.resolve(); // no-op
583+
if (!this.canReceiveRoomEvent(rawEvent.type, rawEvent.content?.["msgtype"])) {
584+
return; // no-op
548585
}
549586
}
550587

551588
// Feed the event into the widget
552-
return this.transport.send<ISendEventToWidgetRequestData>(
589+
await this.transport.send<ISendEventToWidgetRequestData>(
553590
WidgetApiToWidgetAction.SendEvent,
554591
rawEvent as ISendEventToWidgetRequestData, // it's compatible, but missing the index signature
555-
).then();
592+
);
593+
}
594+
595+
/**
596+
* Feeds a to-device event to the widget. If the widget is not able to accept the
597+
* event due to permissions, this will no-op and return calmly. If the widget failed
598+
* to handle the event, this will raise an error.
599+
* @param {IRoomEvent} rawEvent The event to (try to) send to the widget.
600+
* @returns {Promise<void>} Resolves when complete, rejects if there was an error sending.
601+
*/
602+
public async feedToDevice(rawEvent: IRoomEvent): Promise<void> {
603+
if (this.canReceiveToDeviceEvent(rawEvent.type)) {
604+
await this.transport.send<ISendToDeviceToWidgetRequestData>(
605+
WidgetApiToWidgetAction.SendToDevice,
606+
rawEvent as ISendToDeviceToWidgetRequestData, // it's compatible, but missing the index signature
607+
);
608+
}
556609
}
557610
}

src/WidgetApi.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ import {
5353
} from "./interfaces/ModalWidgetActions";
5454
import { ISetModalButtonEnabledActionRequestData } from "./interfaces/SetModalButtonEnabledAction";
5555
import { ISendEventFromWidgetRequestData, ISendEventFromWidgetResponseData } from "./interfaces/SendEventAction";
56+
import {
57+
ISendToDeviceFromWidgetRequestData,
58+
ISendToDeviceFromWidgetResponseData,
59+
} from "./interfaces/SendToDeviceAction";
5660
import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability";
5761
import { INavigateActionRequestData } from "./interfaces/NavigateAction";
5862
import { IReadEventFromWidgetRequestData, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction";
@@ -179,6 +183,26 @@ export class WidgetApi extends EventEmitter {
179183
this.requestCapability(WidgetEventCapability.forStateEvent(EventDirection.Receive, eventType, stateKey).raw);
180184
}
181185

186+
/**
187+
* Requests the capability to send a given to-device event. It is not
188+
* guaranteed to be allowed, but will be asked for if the negotiation has
189+
* not already happened.
190+
* @param {string} eventType The room event type to ask for.
191+
*/
192+
public requestCapabilityToSendToDevice(eventType: string) {
193+
this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw);
194+
}
195+
196+
/**
197+
* Requests the capability to receive a given to-device event. It is not
198+
* guaranteed to be allowed, but will be asked for if the negotiation has
199+
* not already happened.
200+
* @param {string} eventType The room event type to ask for.
201+
*/
202+
public requestCapabilityToReceiveToDevice(eventType: string) {
203+
this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw);
204+
}
205+
182206
/**
183207
* Requests the capability to send a given room event. It is not guaranteed to be
184208
* allowed, but will be asked for if the negotiation has not already happened.
@@ -362,6 +386,16 @@ export class WidgetApi extends EventEmitter {
362386
);
363387
}
364388

389+
public sendToDevice(
390+
eventType: string,
391+
contentMap: { [userId: string]: { [deviceId: string]: unknown } },
392+
): Promise<ISendToDeviceFromWidgetResponseData> {
393+
return this.transport.send<ISendToDeviceFromWidgetRequestData, ISendToDeviceFromWidgetResponseData>(
394+
WidgetApiFromWidgetAction.SendToDevice,
395+
{type: eventType, messages: contentMap},
396+
);
397+
}
398+
365399
public readRoomEvents(
366400
eventType: string,
367401
limit?: number,

src/driver/WidgetDriver.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ export abstract class WidgetDriver {
7575
return Promise.reject(new Error("Failed to override function"));
7676
}
7777

78+
/**
79+
* Sends a to-device event. The widget API will have already verified that the widget
80+
* is capable of sending the event.
81+
* @param {string} eventType The event type to be sent.
82+
* @param {Object} contentMap A map from user ID and device ID to event content.
83+
* @returns {Promise<void>} Resolves when the event has been sent.
84+
* @throws Rejected when the event could not be sent.
85+
*/
86+
public sendToDevice(
87+
eventType: string,
88+
contentMap: { [userId: string]: { [deviceId: string]: unknown } },
89+
): Promise<void> {
90+
return Promise.reject(new Error("Failed to override function"));
91+
}
92+
7893
/**
7994
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
8095
* the user has access to. The widget API will have already verified that the widget is

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export * from "./interfaces/ModalWidgetActions";
5151
export * from "./interfaces/SetModalButtonEnabledAction";
5252
export * from "./interfaces/WidgetConfigAction";
5353
export * from "./interfaces/SendEventAction";
54+
export * from "./interfaces/SendToDeviceAction";
5455
export * from "./interfaces/ReadEventAction";
5556
export * from "./interfaces/IRoomEvent";
5657
export * from "./interfaces/NavigateAction";
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2022 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest";
18+
import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction";
19+
import { IWidgetApiResponseData } from "./IWidgetApiResponse";
20+
import { IRoomEvent } from "./IRoomEvent";
21+
22+
export interface ISendToDeviceFromWidgetRequestData extends IWidgetApiRequestData {
23+
type: string,
24+
messages: { [userId: string]: { [deviceId: string]: unknown } },
25+
}
26+
27+
export interface ISendToDeviceFromWidgetActionRequest extends IWidgetApiRequest {
28+
action: WidgetApiFromWidgetAction.SendToDevice;
29+
data: ISendToDeviceFromWidgetRequestData;
30+
}
31+
32+
export interface ISendToDeviceFromWidgetResponseData extends IWidgetApiResponseData {
33+
// nothing
34+
}
35+
36+
export interface ISendToDeviceFromWidgetActionResponse extends ISendToDeviceFromWidgetActionRequest {
37+
response: ISendToDeviceFromWidgetResponseData;
38+
}
39+
40+
export interface ISendToDeviceToWidgetRequestData extends IWidgetApiRequestData, IRoomEvent {
41+
}
42+
43+
export interface ISendToDeviceToWidgetActionRequest extends IWidgetApiRequest {
44+
action: WidgetApiToWidgetAction.SendToDevice;
45+
data: ISendToDeviceToWidgetRequestData;
46+
}
47+
48+
export interface ISendToDeviceToWidgetResponseData extends IWidgetApiResponseData {
49+
// nothing
50+
}
51+
52+
export interface ISendToDeviceToWidgetActionResponse extends ISendToDeviceToWidgetActionRequest {
53+
response: ISendToDeviceToWidgetResponseData;
54+
}

src/interfaces/WidgetApiAction.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export enum WidgetApiToWidgetAction {
2525
CloseModalWidget = "close_modal",
2626
ButtonClicked = "button_clicked",
2727
SendEvent = "send_event",
28+
SendToDevice = "send_to_device",
2829
}
2930

3031
export enum WidgetApiFromWidgetAction {
@@ -37,6 +38,7 @@ export enum WidgetApiFromWidgetAction {
3738
OpenModalWidget = "open_modal",
3839
SetModalButtonEnabled = "set_button_enabled",
3940
SendEvent = "send_event",
41+
SendToDevice = "send_to_device",
4042

4143
/**
4244
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.

0 commit comments

Comments
 (0)