diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts
new file mode 100644
index 0000000000..662df22813
--- /dev/null
+++ b/cypress/e2e/integration-manager/read_events.spec.ts
@@ -0,0 +1,276 @@
+/*
+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 { SynapseInstance } from "../../plugins/synapsedocker";
+import { UserCredentials } from "../../support/login";
+
+const ROOM_NAME = "Integration Manager Test";
+const USER_DISPLAY_NAME = "Alice";
+
+const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
+const INTEGRATION_MANAGER_HTML = `
+
+
+ Fake Integration Manager
+
+
+
+
+
+
+
+
No response
+
+
+
+`;
+
+function openIntegrationManager() {
+ cy.get(".mx_RightPanel_roomSummaryButton").click();
+ cy.get(".mx_RoomSummaryCard_appsGroup").within(() => {
+ cy.contains("Add widgets, bridges & bots").click();
+ });
+}
+
+function sendActionFromIntegrationManager(
+ integrationManagerUrl: string,
+ targetRoomId: string,
+ eventType: string,
+ stateKey: string | boolean,
+) {
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#target-room-id").should("exist").type(targetRoomId);
+ cy.get("#event-type").should("exist").type(eventType);
+ cy.get("#state-key").should("exist").type(JSON.stringify(stateKey));
+ cy.get("#send-action").should("exist").click();
+ });
+}
+
+describe("Integration Manager: Read Events", () => {
+ let testUser: UserCredentials;
+ let synapse: SynapseInstance;
+ let integrationManagerUrl: string;
+
+ beforeEach(() => {
+ cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => {
+ integrationManagerUrl = url;
+ });
+ cy.startSynapse("default").then((data) => {
+ synapse = data;
+
+ cy.initTestUser(synapse, USER_DISPLAY_NAME, () => {
+ cy.window().then((win) => {
+ win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN);
+ win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN);
+ });
+ }).then((user) => {
+ testUser = user;
+ });
+
+ cy.setAccountData("m.widgets", {
+ "m.integration_manager": {
+ content: {
+ type: "m.integration_manager",
+ name: "Integration Manager",
+ url: integrationManagerUrl,
+ data: {
+ api_url: integrationManagerUrl,
+ },
+ },
+ id: "integration-manager",
+ },
+ }).as("integrationManager");
+
+ // Succeed when checking the token is valid
+ cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => {
+ req.continue((res) => {
+ return res.send(200, {
+ user_id: testUser.userId,
+ });
+ });
+ });
+
+ cy.createRoom({
+ name: ROOM_NAME,
+ }).as("roomId");
+ });
+ });
+
+ afterEach(() => {
+ cy.stopSynapse(synapse);
+ cy.stopWebServers();
+ });
+
+ it("should read a state event by state key", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ const eventType = "io.element.integrations.installations";
+ const eventContent = {
+ foo: "bar",
+ };
+ const stateKey = "state-key-123";
+
+ // Send a state event
+ cy.getClient()
+ .then(async (client) => {
+ return await client.sendStateEvent(roomId, eventType, eventContent, stateKey);
+ })
+ .then((event) => {
+ openIntegrationManager();
+
+ // Read state events
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response")
+ .should("include.text", event.event_id)
+ .should("include.text", `"content":${JSON.stringify(eventContent)}`);
+ });
+ });
+ });
+ });
+
+ it("should read a state event with empty state key", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ const eventType = "io.element.integrations.installations";
+ const eventContent = {
+ foo: "bar",
+ };
+ const stateKey = "";
+
+ // Send a state event
+ cy.getClient()
+ .then(async (client) => {
+ return await client.sendStateEvent(roomId, eventType, eventContent, stateKey);
+ })
+ .then((event) => {
+ openIntegrationManager();
+
+ // Read state events
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response")
+ .should("include.text", event.event_id)
+ .should("include.text", `"content":${JSON.stringify(eventContent)}`);
+ });
+ });
+ });
+ });
+
+ it("should read state events with any state key", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ const eventType = "io.element.integrations.installations";
+
+ const stateKey1 = "state-key-123";
+ const eventContent1 = {
+ foo1: "bar1",
+ };
+ const stateKey2 = "state-key-456";
+ const eventContent2 = {
+ foo2: "bar2",
+ };
+ const stateKey3 = "state-key-789";
+ const eventContent3 = {
+ foo3: "bar3",
+ };
+
+ // Send state events
+ cy.getClient()
+ .then(async (client) => {
+ return Promise.all([
+ client.sendStateEvent(roomId, eventType, eventContent1, stateKey1),
+ client.sendStateEvent(roomId, eventType, eventContent2, stateKey2),
+ client.sendStateEvent(roomId, eventType, eventContent3, stateKey3),
+ ]);
+ })
+ .then((events) => {
+ openIntegrationManager();
+
+ // Read state events
+ sendActionFromIntegrationManager(
+ integrationManagerUrl,
+ roomId,
+ eventType,
+ true, // Any state key
+ );
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response")
+ .should("include.text", events[0].event_id)
+ .should("include.text", `"content":${JSON.stringify(eventContent1)}`)
+ .should("include.text", events[1].event_id)
+ .should("include.text", `"content":${JSON.stringify(eventContent2)}`)
+ .should("include.text", events[2].event_id)
+ .should("include.text", `"content":${JSON.stringify(eventContent3)}`);
+ });
+ });
+ });
+ });
+
+ it("should fail to read an event type which is not allowed", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ const eventType = "com.example.event";
+ const stateKey = "";
+
+ openIntegrationManager();
+
+ // Read state events
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response").should("include.text", "Failed to read events");
+ });
+ });
+ });
+});
diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts
new file mode 100644
index 0000000000..7b706b047d
--- /dev/null
+++ b/cypress/e2e/integration-manager/send_event.spec.ts
@@ -0,0 +1,261 @@
+/*
+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 { SynapseInstance } from "../../plugins/synapsedocker";
+import { UserCredentials } from "../../support/login";
+
+const ROOM_NAME = "Integration Manager Test";
+const USER_DISPLAY_NAME = "Alice";
+
+const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
+const INTEGRATION_MANAGER_HTML = `
+
+
+ Fake Integration Manager
+
+
+
+
+
+
+
+
+
No response
+
+
+
+`;
+
+function openIntegrationManager() {
+ cy.get(".mx_RightPanel_roomSummaryButton").click();
+ cy.get(".mx_RoomSummaryCard_appsGroup").within(() => {
+ cy.contains("Add widgets, bridges & bots").click();
+ });
+}
+
+function sendActionFromIntegrationManager(
+ integrationManagerUrl: string,
+ targetRoomId: string,
+ eventType: string,
+ stateKey: string,
+ content: Record,
+) {
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#target-room-id").should("exist").type(targetRoomId);
+ cy.get("#event-type").should("exist").type(eventType);
+ if (stateKey) {
+ cy.get("#state-key").should("exist").type(stateKey);
+ }
+ cy.get("#event-content").should("exist").type(JSON.stringify(content), { parseSpecialCharSequences: false });
+ cy.get("#send-action").should("exist").click();
+ });
+}
+
+describe("Integration Manager: Send Event", () => {
+ let testUser: UserCredentials;
+ let synapse: SynapseInstance;
+ let integrationManagerUrl: string;
+
+ beforeEach(() => {
+ cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => {
+ integrationManagerUrl = url;
+ });
+ cy.startSynapse("default").then((data) => {
+ synapse = data;
+
+ cy.initTestUser(synapse, USER_DISPLAY_NAME, () => {
+ cy.window().then((win) => {
+ win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN);
+ win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN);
+ });
+ }).then((user) => {
+ testUser = user;
+ });
+
+ cy.setAccountData("m.widgets", {
+ "m.integration_manager": {
+ content: {
+ type: "m.integration_manager",
+ name: "Integration Manager",
+ url: integrationManagerUrl,
+ data: {
+ api_url: integrationManagerUrl,
+ },
+ },
+ id: "integration-manager",
+ },
+ }).as("integrationManager");
+
+ // Succeed when checking the token is valid
+ cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => {
+ req.continue((res) => {
+ return res.send(200, {
+ user_id: testUser.userId,
+ });
+ });
+ });
+
+ cy.createRoom({
+ name: ROOM_NAME,
+ }).as("roomId");
+ });
+ });
+
+ afterEach(() => {
+ cy.stopSynapse(synapse);
+ cy.stopWebServers();
+ });
+
+ it("should send a state event", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ openIntegrationManager();
+
+ const eventType = "io.element.integrations.installations";
+ const eventContent = {
+ foo: "bar",
+ };
+ const stateKey = "state-key-123";
+
+ // Send the event
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response").should("include.text", "event_id");
+ });
+
+ // Check the event
+ cy.getClient()
+ .then(async (client) => {
+ return await client.getStateEvent(roomId, eventType, stateKey);
+ })
+ .then((event) => {
+ expect(event).to.deep.equal(eventContent);
+ });
+ });
+ });
+
+ it("should send a state event with empty content", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ openIntegrationManager();
+
+ const eventType = "io.element.integrations.installations";
+ const eventContent = {};
+ const stateKey = "state-key-123";
+
+ // Send the event
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response").should("include.text", "event_id");
+ });
+
+ // Check the event
+ cy.getClient()
+ .then(async (client) => {
+ return await client.getStateEvent(roomId, eventType, stateKey);
+ })
+ .then((event) => {
+ expect(event).to.be.empty;
+ });
+ });
+ });
+
+ it("should send a state event with empty state key", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ openIntegrationManager();
+
+ const eventType = "io.element.integrations.installations";
+ const eventContent = {
+ foo: "bar",
+ };
+ const stateKey = "";
+
+ // Send the event
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response").should("include.text", "event_id");
+ });
+
+ // Check the event
+ cy.getClient()
+ .then(async (client) => {
+ return await client.getStateEvent(roomId, eventType, stateKey);
+ })
+ .then((event) => {
+ expect(event).to.deep.equal(eventContent);
+ });
+ });
+ });
+
+ it("should fail to send an event type which is not allowed", () => {
+ cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => {
+ cy.viewRoomByName(ROOM_NAME);
+
+ openIntegrationManager();
+
+ const eventType = "com.example.event";
+ const eventContent = {
+ foo: "bar",
+ };
+ const stateKey = "";
+
+ // Send the event
+ sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent);
+
+ // Check the response
+ cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
+ cy.get("#message-response").should("include.text", "Failed to send event");
+ });
+ });
+ });
+});
diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts
index 14c56bef41..b1912c484a 100644
--- a/src/ScalarMessaging.ts
+++ b/src/ScalarMessaging.ts
@@ -264,10 +264,36 @@ Get an openID token for the current user session.
Request: No parameters
Response:
- The openId token object as described in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridopenidrequest_token
+
+send_event
+----------
+Sends an event in a room.
+
+Request:
+ - type is the event type to send.
+ - state_key is the state key to send. Omitted if not a state event.
+ - content is the event content to send.
+
+Response:
+ - room_id is the room ID where the event was sent.
+ - event_id is the event ID of the event which was sent.
+
+read_events
+-----------
+Read events from a room.
+
+Request:
+ - type is the event type to read.
+ - state_key is the state key to read, or `true` to read all events of the type. Omitted if not a state event.
+
+Response:
+ - events: Array of events. If none found, this will be an empty array.
+
*/
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
+import { IEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
@@ -295,6 +321,8 @@ enum Action {
SetBotOptions = "set_bot_options",
SetBotPower = "set_bot_power",
GetOpenIdToken = "get_open_id_token",
+ SendEvent = "send_event",
+ ReadEvents = "read_events",
}
function sendResponse(event: MessageEvent, res: any): void {
@@ -468,13 +496,13 @@ function setWidget(event: MessageEvent, roomId: string | null): void {
}
}
-function getWidgets(event: MessageEvent, roomId: string): void {
+function getWidgets(event: MessageEvent, roomId: string | null): void {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t("You need to be logged in."));
return;
}
- let widgetStateEvents = [];
+ let widgetStateEvents: Partial[] = [];
if (roomId) {
const room = client.getRoom(roomId);
@@ -693,6 +721,141 @@ async function getOpenIdToken(event: MessageEvent) {
}
}
+async function sendEvent(
+ event: MessageEvent<{
+ type: string;
+ state_key?: string;
+ content?: IContent;
+ }>,
+ roomId: string,
+) {
+ const eventType = event.data.type;
+ const stateKey = event.data.state_key;
+ const content = event.data.content;
+
+ if (typeof eventType !== "string") {
+ sendError(event, _t("Failed to send event"), new Error("Invalid 'type' in request"));
+ return;
+ }
+ const allowedEventTypes = ["m.widgets", "im.vector.modular.widgets", "io.element.integrations.installations"];
+ if (!allowedEventTypes.includes(eventType)) {
+ sendError(event, _t("Failed to send event"), new Error("Disallowed 'type' in request"));
+ return;
+ }
+
+ if (!content || typeof content !== "object") {
+ sendError(event, _t("Failed to send event"), new Error("Invalid 'content' in request"));
+ return;
+ }
+
+ const client = MatrixClientPeg.get();
+ if (!client) {
+ sendError(event, _t("You need to be logged in."));
+ return;
+ }
+
+ const room = client.getRoom(roomId);
+ if (!room) {
+ sendError(event, _t("This room is not recognised."));
+ return;
+ }
+
+ if (stateKey !== undefined) {
+ // state event
+ try {
+ const res = await client.sendStateEvent(roomId, eventType, content, stateKey);
+ sendResponse(event, {
+ room_id: roomId,
+ event_id: res.event_id,
+ });
+ } catch (e) {
+ sendError(event, _t("Failed to send event"), e as Error);
+ return;
+ }
+ } else {
+ // message event
+ sendError(event, _t("Failed to send event"), new Error("Sending message events is not implemented"));
+ return;
+ }
+}
+
+async function readEvents(
+ event: MessageEvent<{
+ type: string;
+ state_key?: string | boolean;
+ limit?: number;
+ }>,
+ roomId: string,
+) {
+ const eventType = event.data.type;
+ const stateKey = event.data.state_key;
+ const limit = event.data.limit;
+
+ if (typeof eventType !== "string") {
+ sendError(event, _t("Failed to read events"), new Error("Invalid 'type' in request"));
+ return;
+ }
+ const allowedEventTypes = [
+ "m.room.power_levels",
+ "m.room.encryption",
+ "m.room.member",
+ "m.room.name",
+ "m.widgets",
+ "im.vector.modular.widgets",
+ "io.element.integrations.installations",
+ ];
+ if (!allowedEventTypes.includes(eventType)) {
+ sendError(event, _t("Failed to read events"), new Error("Disallowed 'type' in request"));
+ return;
+ }
+
+ let effectiveLimit: number;
+ if (limit !== undefined) {
+ if (typeof limit !== "number" || limit < 0) {
+ sendError(event, _t("Failed to read events"), new Error("Invalid 'limit' in request"));
+ return;
+ }
+ effectiveLimit = Math.min(limit, Number.MAX_SAFE_INTEGER);
+ } else {
+ effectiveLimit = Number.MAX_SAFE_INTEGER;
+ }
+
+ const client = MatrixClientPeg.get();
+ if (!client) {
+ sendError(event, _t("You need to be logged in."));
+ return;
+ }
+
+ const room = client.getRoom(roomId);
+ if (!room) {
+ sendError(event, _t("This room is not recognised."));
+ return;
+ }
+
+ if (stateKey !== undefined) {
+ // state events
+ if (typeof stateKey !== "string" && stateKey !== true) {
+ sendError(event, _t("Failed to read events"), new Error("Invalid 'state_key' in request"));
+ return;
+ }
+ // When `true` is passed for state key, get events with any state key.
+ const effectiveStateKey = stateKey === true ? undefined : stateKey;
+
+ let events: MatrixEvent[] = [];
+ events = events.concat(room.currentState.getStateEvents(eventType, effectiveStateKey as string) || []);
+ events = events.slice(0, effectiveLimit);
+
+ sendResponse(event, {
+ events: events.map((e) => e.getEffectiveEvent()),
+ });
+ return;
+ } else {
+ // message events
+ sendError(event, _t("Failed to read events"), new Error("Reading message events is not implemented"));
+ return;
+ }
+}
+
const onMessage = function (event: MessageEvent): void {
if (!event.origin) {
// stupid chrome
@@ -786,6 +949,12 @@ const onMessage = function (event: MessageEvent): void {
} else if (event.data.action === Action.CanSendEvent) {
canSendEvent(event, roomId);
return;
+ } else if (event.data.action === Action.SendEvent) {
+ sendEvent(event, roomId);
+ return;
+ } else if (event.data.action === Action.ReadEvents) {
+ readEvents(event, roomId);
+ return;
}
if (!userId) {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 306e048355..7cf87238eb 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -390,6 +390,8 @@
"Power level must be positive integer.": "Power level must be positive integer.",
"You are not in this room.": "You are not in this room.",
"You do not have permission to do that in this room.": "You do not have permission to do that in this room.",
+ "Failed to send event": "Failed to send event",
+ "Failed to read events": "Failed to read events",
"Missing room_id in request": "Missing room_id in request",
"Room %(roomId)s not visible": "Room %(roomId)s not visible",
"Missing user_id in request": "Missing user_id in request",