diff --git a/changelog.d/185.misc b/changelog.d/185.misc new file mode 100644 index 00000000..fd713d1f --- /dev/null +++ b/changelog.d/185.misc @@ -0,0 +1 @@ +Convert intent.js to TypeScript \ No newline at end of file diff --git a/changelog.d/194.feature b/changelog.d/194.feature new file mode 100644 index 00000000..331e3765 --- /dev/null +++ b/changelog.d/194.feature @@ -0,0 +1 @@ +Bump matrix-js-sdk to 8.0.1 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a7846c1f..4a8a5153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -236,9 +236,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.10.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz", - "integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", + "integrity": "sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==", "requires": { "regenerator-runtime": "^0.13.4" } @@ -2772,9 +2772,9 @@ } }, "matrix-js-sdk": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-6.2.2.tgz", - "integrity": "sha512-uTXmKVzl7GXM3R70cl2E87H+mtwN3ILqPeB80Z/2ITN9Vaf9pMURCCAuHePEBXbhnD7DOZj7Cke42uP+ByR6Hw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-8.0.1.tgz", + "integrity": "sha512-DT2YjWi8l2eHyNTKZOhBkN/EakIMDDEglSCg+RWY4QzFaXYlSwfwfzzCujjtq1hxVSKim8NC7KqxgNetOiBegA==", "requires": { "@babel/runtime": "^7.8.3", "another-json": "^0.2.0", @@ -3412,9 +3412,9 @@ } }, "regenerator-runtime": { - "version": "0.13.5", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", - "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" }, "regex-not": { "version": "1.0.2", diff --git a/package.json b/package.json index c7b9618f..316b3bff 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "is-my-json-valid": "^2.20.5", "js-yaml": "^3.14.0", "matrix-appservice": "^0.4.2", - "matrix-js-sdk": "^6.0.0", + "matrix-js-sdk": "^8.0.1", "nedb": "^1.8.0", "nopt": "^4.0.3", "p-queue": "^6.6.0", diff --git a/spec/unit/intent.spec.js b/spec/unit/intent.spec.js index 8e591829..be76b737 100644 --- a/spec/unit/intent.spec.js +++ b/spec/unit/intent.spec.js @@ -1,21 +1,21 @@ "use strict"; -var Intent = require("../..").Intent; -var log = require("../log"); +const Intent = require("../..").Intent; +const log = require("../log"); describe("Intent", function() { - var intent, client, botClient; - var userId = "@alice:bar"; - var botUserId = "@bot:user"; - var roomId = "!foo:bar"; + let intent, client, botClient; + const userId = "@alice:bar"; + const botUserId = "@bot:user"; + const roomId = "!foo:bar"; + const alreadyRegistered = { + registered: true + }; beforeEach( /** @this */ function() { log.beforeEach(this); - var alreadyRegistered = { - registered: true - }; - var clientFields = [ + const clientFields = [ "credentials", "joinRoom", "invite", "leave", "ban", "unban", "kick", "getStateEvent", "setPowerLevel", "sendTyping", "sendEvent", "sendStateEvent", "setDisplayName", "setAvatarUrl", @@ -150,9 +150,9 @@ describe("Intent", function() { }); describe("sending state events", function() { - var validPowerLevels, invalidPowerLevels; + let validPowerLevels, invalidPowerLevels; - beforeEach(function() { + beforeEach(() => { // not interested in joins, so no-op them. intent.onEvent({ event_id: "test", @@ -164,7 +164,7 @@ describe("Intent", function() { } }); - var basePowerLevelEvent = { + const basePowerLevelEvent = { content: { "ban": 50, "events": { @@ -259,13 +259,16 @@ describe("Intent", function() { }); describe("sending message events", function() { - var content = { + const content = { body: "hello world", msgtype: "m.text", }; beforeEach(function() { - intent.opts.dontCheckPowerLevel = true; + intent = new Intent(client, botClient, { + ...alreadyRegistered, + dontCheckPowerLevel: true, + }); // not interested in joins, so no-op them. intent.onEvent({ event_id: "test", @@ -304,7 +307,7 @@ describe("Intent", function() { }); it("should try to join the room on M_FORBIDDEN then resend", function() { - var isJoined = false; + let isJoined = false; client.sendEvent.and.callFake(function() { if (isJoined) { return Promise.resolve({ @@ -350,7 +353,7 @@ describe("Intent", function() { }); it("should fail if the resend after M_FORBIDDEN fails", function() { - var isJoined = false; + let isJoined = false; client.sendEvent.and.callFake(function() { if (isJoined) { return Promise.reject({ @@ -380,10 +383,13 @@ describe("Intent", function() { describe("signaling bridge error", function() { const reason = "m.event_not_handled" - var affectedUsers, eventId, bridge; + let affectedUsers, eventId, bridge; beforeEach(function() { - intent.opts.dontCheckPowerLevel = true; + intent = new Intent(client, botClient, { + ...alreadyRegistered, + dontCheckPowerLevel: true, + }); // not interested in joins, so no-op them. intent.onEvent({ event_id: "test", diff --git a/spec/unit/state-lookup.spec.js b/spec/unit/state-lookup.spec.js index 915ddd4c..aed6de1b 100644 --- a/spec/unit/state-lookup.spec.js +++ b/spec/unit/state-lookup.spec.js @@ -18,34 +18,31 @@ describe("StateLookup", function() { }); }); - describe("trackRoom", function() { + describe("trackRoom", () => { it("should return a Promise which is resolved after the HTTP call " + - "to /state returns", function(done) { + "to /state returns", async() => { var statePromise = createStatePromise([]); cli.roomState.and.returnValue(statePromise.promise); var p = lookup.trackRoom("!foo:bar"); expect(p.isPending()).toBe(true); // not resolved HTTP call yet - Bluebird.delay(5).then(function() { - expect(p.isPending()).toBe(true); // still not resolved HTTP call - statePromise.resolve(); - return p; // Should resolve now HTTP call is resolved - }).then(function() { - done(); - }); + await Bluebird.delay(5); + expect(p.isPending()).toBe(true); // still not resolved HTTP call + statePromise.resolve(); + await p; // Should resolve now HTTP call is resolved }); it("should return the same Promise if called multiple times with the " + "same room ID", function() { - var statePromise = createStatePromise([]); + const statePromise = createStatePromise([]); cli.roomState.and.returnValue(statePromise.promise); - var p = lookup.trackRoom("!foo:bar"); - var q = lookup.trackRoom("!foo:bar"); + const p = lookup.trackRoom("!foo:bar"); + const q = lookup.trackRoom("!foo:bar"); expect(p).toBe(q); }); - it("should be able to have >1 in-flight track requests at once", function(done) { - var stateA = createStatePromise([]); - var stateB = createStatePromise([]); + it("should be able to have >1 in-flight track requests at once", async() => { + const stateA = createStatePromise([]); + const stateB = createStatePromise([]); cli.roomState.and.callFake(function(roomId) { if (roomId === "!a:foobar") { return stateA.promise; @@ -55,16 +52,13 @@ describe("StateLookup", function() { } throw new Error("Unexpected room ID: " + roomId); }); - var promiseA = lookup.trackRoom("!a:foobar"); - var promiseB = lookup.trackRoom("!b:foobar"); + const promiseA = lookup.trackRoom("!a:foobar"); + const promiseB = lookup.trackRoom("!b:foobar"); stateA.resolve(); - promiseA.then(function() { - expect(promiseB.isPending()).toBe(true); - stateB.resolve(); - return promiseB; - }).then(function() { - done(); - }); + await promiseA; + expect(promiseB.isPending()).toBe(true); + stateB.resolve(); + await promiseB; }); it("should retry the HTTP call on non 4xx, 5xx errors", async function() { @@ -109,28 +103,28 @@ describe("StateLookup", function() { }); }); - describe("onEvent", function() { - it("should update the state lookup map", function(done) { - cli.roomState.and.callFake(function(roomId) { - return Promise.resolve([ - {type: "m.room.name", state_key: "", room_id: "!foo:bar", - content: { name: "Foo" }} - ]); + describe("onEvent", () => { + it("should update the state lookup map", async() => { + cli.roomState.and.callFake(async(roomId) => { + return [{ + type: "m.room.name", + state_key: "", + room_id: "!foo:bar", + content: { name: "Foo" }, + }]; }); - lookup.trackRoom("!foo:bar").then(function() { - expect( - lookup.getState("!foo:bar", "m.room.name", "").content.name - ).toEqual("Foo"); - lookup.onEvent( - {type: "m.room.name", state_key: "", room_id: "!foo:bar", - content: { name: "Bar" }} - ); - expect( - lookup.getState("!foo:bar", "m.room.name", "").content.name - ).toEqual("Bar"); - done(); - }); + await lookup.trackRoom("!foo:bar") + expect( + lookup.getState("!foo:bar", "m.room.name", "").content.name + ).toEqual("Foo"); + lookup.onEvent( + {type: "m.room.name", state_key: "", room_id: "!foo:bar", + content: { name: "Bar" }} + ); + expect( + lookup.getState("!foo:bar", "m.room.name", "").content.name + ).toEqual("Bar"); }); it("should clobber events from in-flight track requests", async() => { @@ -154,10 +148,10 @@ describe("StateLookup", function() { }); }); - describe("getState", function() { - beforeEach(function(done) { - cli.roomState.and.callFake(function(roomId) { - return Promise.resolve([ + describe("getState", () => { + beforeEach(async() => { + cli.roomState.and.callFake(async(roomId) => { + return [ {type: "m.room.name", state_key: "", content: { name: "Foo" }}, {type: "m.room.topic", state_key: "", content: { name: "Bar" }}, {type: "m.room.member", state_key: "@alice:bar", content: { @@ -168,12 +162,10 @@ describe("StateLookup", function() { displayname: "Bob", membership: "invite" }}, - ]); + ]; }); - lookup.trackRoom("!foo:bar").then(function() { - done(); - }); + await lookup.trackRoom("!foo:bar"); }); it("should return null for no match with state_key", function() { diff --git a/src/bridge.js b/src/bridge.js index 2b380e10..e6a03299 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -27,7 +27,7 @@ const BridgeContext = require("./components/bridge-context"); const ClientFactory = require("./components/client-factory"); const AppServiceBot = require("./components/app-service-bot"); const RequestFactory = require("./components/request-factory").RequestFactory; -const Intent = require("./components/intent"); +const Intent = require("./components/intent").Intent; const RoomBridgeStore = require("./components/room-bridge-store"); const UserBridgeStore = require("./components/user-bridge-store"); const EventBridgeStore = require("./components/event-bridge-store"); diff --git a/src/components/intent.js b/src/components/intent.js deleted file mode 100644 index 89684de3..00000000 --- a/src/components/intent.js +++ /dev/null @@ -1,828 +0,0 @@ -/* -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. -*/ - -const Promise = require("bluebird"); -const MatrixUser = require("../models/users/matrix"); -const MatrixEvent = require("matrix-js-sdk").MatrixEvent; -const RoomMember = require("matrix-js-sdk").RoomMember; -const ClientRequestCache = require("./client-request-cache"); -const defer = require("../utils/promiseutil").defer; - -const STATE_EVENT_TYPES = [ - "m.room.name", "m.room.topic", "m.room.power_levels", "m.room.member", - "m.room.join_rules", "m.room.history_visibility" -]; -const DEFAULT_CACHE_TTL = 90000; -const DEFAULT_CACHE_SIZE = 1024; - -/** - * Create an entity which can fulfil the intent of a given user. - * @constructor - * @param {MatrixClient} client The matrix client instance whose intent is being - * fulfilled e.g. the entity joining the room when you call intent.join(roomId). - * @param {MatrixClient} botClient The client instance for the AS bot itself. - * This will be used to perform more priveleged actions such as creating new - * rooms, sending invites, etc. - * @param {Object} opts Options for this Intent instance. - * @param {boolean} opts.registered True to inform this instance that the client - * is already registered. No registration requests will be made from this Intent. - * Default: false. - * @param {boolean} opts.dontCheckPowerLevel True to not check for the right power - * level before sending events. Default: false. - * - * @param {Object=} opts.backingStore An object with 4 functions, outlined below. - * If this Object is supplied, ALL 4 functions must be supplied. If this Object - * is not supplied, the Intent will maintain its own backing store for membership - * and power levels, which may scale badly for lots of users. - * - * @param {Function} opts.backingStore.getMembership A function which is called with a - * room ID and user ID which should return the membership status of this user as - * a string e.g "join". `null` should be returned if the membership is unknown. - * - * @param {Function} opts.backingStore.getPowerLevelContent A function which is called - * with a room ID which should return the power level content for this room, as an Object. - * `null` should be returned if there is no known content. - * - * @param {Function} opts.backingStore.setMembership A function with the signature: - * function(roomId, userId, membership) which will set the membership of the given user in - * the given room. This has no return value. - * - * @param {Function} opts.backingStore.setPowerLevelContent A function with the signature: - * function(roomId, content) which will set the power level content in the given room. - * This has no return value. - * - * @param {boolean} opts.dontJoin True to not attempt to join a room before - * sending messages into it. The surrounding code will have to ensure the correct - * membership state itself in this case. Default: false. - * - * @param {boolean} [opts.enablePresence=true] True to send presence, false to no-op. - * - * @param {Number} opts.caching.ttl How long requests can stay in the cache, in milliseconds. - * @param {Number} opts.caching.size How many entries should be kept in the cache, before the oldest is dropped. - */ -function Intent(client, botClient, opts) { - this.client = client; - this.botClient = botClient; - opts = opts || {}; - - opts.enablePresence = opts.enablePresence !== false; - - if (opts.backingStore) { - if (!opts.backingStore.setPowerLevelContent || - !opts.backingStore.getPowerLevelContent || - !opts.backingStore.setMembership || - !opts.backingStore.getMembership) { - throw new Error("Intent backingStore missing required functions"); - } - } - else { - this._membershipStates = { - // room_id : "join|invite|leave|null" null=unknown - }; - this._powerLevels = { - // room_id: event.content - }; - var self = this; - - opts.backingStore = { - getMembership: function(roomId, userId) { - if (userId !== self.client.credentials.userId) { - return null; - } - return self._membershipStates[roomId]; - }, - setMembership: function(roomId, userId, membership) { - if (userId !== self.client.credentials.userId) { - return; - } - self._membershipStates[roomId] = membership; - }, - setPowerLevelContent: function(roomId, content) { - self._powerLevels[roomId] = content; - }, - getPowerLevelContent: function(roomId) { - return self._powerLevels[roomId]; - } - } - } - - if (!opts.caching) { - opts.caching = { }; - } - - opts.caching.ttl = opts.caching.ttl === undefined ? DEFAULT_CACHE_TTL : opts.caching.ttl; - opts.caching.size = opts.caching.size === undefined ? DEFAULT_CACHE_SIZE : opts.caching.ttl; - this._requestCaches = {}; - this._requestCaches.profile = new ClientRequestCache( - opts.caching.ttl, - opts.caching.size, - (_, userId, info) => { - return this.getProfileInfo(userId, info, false); - } - ); - this._requestCaches.roomstate = new ClientRequestCache( - opts.caching.ttl, - opts.caching.size, - (roomId) => { - return this.roomState(roomId, false); - } - ); - this._requestCaches.event = new ClientRequestCache( - opts.caching.ttl, - opts.caching.size, - (_, roomId, eventId) => { - return this.getEvent(roomId, eventId, false); - } - ); - - this.opts = opts; -} - -/** - * Return the client this Intent is acting on behalf of. - * @return {MatrixClient} The client - */ -Intent.prototype.getClient = function() { - return this.client; -}; - -/** - *

Send a plaintext message to a room.

- * This will automatically make the client join the room so they can send the - * message if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} text The text string to send. - * @return {Promise} - */ -Intent.prototype.sendText = function(roomId, text) { - return this.sendMessage(roomId, { - body: text, - msgtype: "m.text" - }); -}; - -/** - *

Set the name of a room.

- * This will automatically make the client join the room so they can set the - * name if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} name The room name. - * @return {Promise} - */ -Intent.prototype.setRoomName = function(roomId, name) { - return this.sendStateEvent(roomId, "m.room.name", "", { - name: name - }); -}; - -/** - *

Set the topic of a room.

- * This will automatically make the client join the room so they can set the - * topic if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} topic The room topic. - * @return {Promise} - */ -Intent.prototype.setRoomTopic = function(roomId, topic) { - return this.sendStateEvent(roomId, "m.room.topic", "", { - topic: topic - }); -}; - -/** - *

Set the avatar of a room.

- * This will automatically make the client join the room so they can set the - * topic if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} avatar The url of the avatar. - * @param {string} info Extra information about the image. See m.room.avatar for details. - * @return {Promise} - */ -Intent.prototype.setRoomAvatar = function(roomId, avatar, info) { - var content = { - url: avatar - }; - if (info) { - content.info = info; - } - return this.sendStateEvent(roomId, "m.room.avatar", "", content); -}; - -/** - *

Send a typing event to a room.

- * This will automatically make the client join the room so they can send the - * typing event if they are not already joined. - * @param {string} roomId The room to send to. - * @param {boolean} isTyping True if typing - * @return {Promise} - */ -Intent.prototype.sendTyping = function(roomId, isTyping) { - var self = this; - return self._ensureJoined(roomId).then(function() { - return self._ensureHasPowerLevelFor(roomId, "m.typing"); - }).then(function() { - return self.client.sendTyping(roomId, isTyping); - }); -}; - -/** - *

Send a read receipt to a room.

- * This will automatically make the client join the room so they can send the - * receipt event if they are not already joined. - * @param{string} roomId The room to send to. - * @param{string} eventId The event ID to set the receipt mark to. - * @return {Promise} - */ -Intent.prototype.sendReadReceipt = function(roomId, eventId) { - var self = this; - var event = new MatrixEvent({ - room_id: roomId, - event_id: eventId, - }); - return self._ensureJoined(roomId).then(function() { - return self.client.sendReadReceipt(event); - }); -} - -/** - * Set the power level of the given target. - * @param {string} roomId The room to set the power level in. - * @param {string} target The target user ID - * @param {number} level The desired level - * @return {Promise} - */ -Intent.prototype.setPowerLevel = function(roomId, target, level) { - var self = this; - return self._ensureJoined(roomId).then(function() { - return self._ensureHasPowerLevelFor(roomId, "m.room.power_levels"); - }).then(function(event) { - return self.client.setPowerLevel(roomId, target, level, event); - }); -}; - -/** - *

Send an m.room.message event to a room.

- * This will automatically make the client join the room so they can send the - * message if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {Object} content The event content - * @return {Promise} - */ -Intent.prototype.sendMessage = function(roomId, content) { - return this.sendEvent(roomId, "m.room.message", content); -}; - -/** - *

Send a message event to a room.

- * This will automatically make the client join the room so they can send the - * message if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} type The event type - * @param {Object} content The event content - * @return {Promise} - */ -Intent.prototype.sendEvent = function(roomId, type, content) { - var self = this; - return self._ensureJoined(roomId).then(function() { - return self._ensureHasPowerLevelFor(roomId, type); - }).then(self._joinGuard(roomId, function() { - return self.client.sendEvent(roomId, type, content); - })); -}; - -/** - *

Send a state event to a room.

- * This will automatically make the client join the room so they can send the - * state if they are not already joined. It will also make sure that the client - * has sufficient power level to do this. - * @param {string} roomId The room to send to. - * @param {string} type The event type - * @param {string} skey The state key - * @param {Object} content The event content - * @return {Promise} - */ -Intent.prototype.sendStateEvent = function(roomId, type, skey, content) { - var self = this; - return self._ensureJoined(roomId).then(function() { - return self._ensureHasPowerLevelFor(roomId, type); - }).then(self._joinGuard(roomId, function() { - return self.client.sendStateEvent(roomId, type, content, skey); - })); -}; - -/** - *

Get the current room state for a room.

- * This will automatically make the client join the room so they can get the - * state if they are not already joined. - * @param {string} roomId The room to get the state from. - * @param {boolean} [useCache=false] Should the request attempt to lookup - * state from the cache. - * @return {Promise} - */ -Intent.prototype.roomState = function(roomId, useCache=false) { - return this._ensureJoined(roomId).then(() => { - if (useCache) { - return this._requestCaches.roomstate.get(roomId); - } - return this.client.roomState(roomId); - }); -}; - -/** - * Create a room with a set of options. - * @param {Object} opts Options. - * @param {boolean} opts.createAsClient True to create this room as a client and - * not the bot: the bot will not join. False to create this room as the bot and - * auto-join the client. Default: false. - * @param {Object} opts.options Options to pass to the client SDK /createRoom API. - * @return {Promise} - */ -Intent.prototype.createRoom = function(opts) { - var self = this; - var cli = opts.createAsClient ? this.client : this.botClient; - var options = opts.options || {}; - if (!opts.createAsClient) { - // invite the client if they aren't already - options.invite = options.invite || []; - if (options.invite.indexOf(this.client.credentials.userId) === -1) { - options.invite.push(this.client.credentials.userId); - } - } - // make sure that the thing doing the room creation isn't inviting itself - // else Synapse hard fails the operation with M_FORBIDDEN - if (options.invite && options.invite.indexOf(cli.credentials.userId) !== -1) { - options.invite.splice(options.invite.indexOf(cli.credentials.userId), 1); - } - - return this._ensureRegistered().then(function() { - return cli.createRoom(options); - }).then(function(res) { - // create a fake power level event to give the room creator ops if we - // don't yet have a power level event. - if (self.opts.backingStore.getPowerLevelContent(res.room_id)) { - return res; - } - var users = {}; - users[cli.credentials.userId] = 100; - self.opts.backingStore.setPowerLevelContent(res.room_id, { - users_default: 0, - events_default: 0, - state_default: 50, - users: users, - events: {} - }); - return res; - }); -}; - -/** - *

Invite a user to a room.

- * This will automatically make the client join the room so they can send the - * invite if they are not already joined. - * @param {string} roomId The room to invite the user to. - * @param {string} target The user ID to invite. - * @return {Promise} Resolved when invited, else rejected with an error. - */ -Intent.prototype.invite = function(roomId, target) { - var self = this; - return this._ensureJoined(roomId).then(function() { - return self.client.invite(roomId, target); - }); -}; - -/** - *

Kick a user from a room.

- * This will automatically make the client join the room so they can send the - * kick if they are not already joined. - * @param {string} roomId The room to kick the user from. - * @param {string} target The target of the kick operation. - * @param {string} reason Optional. The reason for the kick. - * @return {Promise} Resolved when kickked, else rejected with an error. - */ -Intent.prototype.kick = function(roomId, target, reason) { - var self = this; - return this._ensureJoined(roomId).then(function() { - return self.client.kick(roomId, target, reason); - }); -}; - -/** - *

Ban a user from a room.

- * This will automatically make the client join the room so they can send the - * ban if they are not already joined. - * @param {string} roomId The room to ban the user from. - * @param {string} target The target of the ban operation. - * @param {string} reason Optional. The reason for the ban. - * @return {Promise} Resolved when banned, else rejected with an error. - */ -Intent.prototype.ban = function(roomId, target, reason) { - var self = this; - return this._ensureJoined(roomId).then(function() { - return self.client.ban(roomId, target, reason); - }); -}; - -/** - *

Unban a user from a room.

- * This will automatically make the client join the room so they can send the - * unban if they are not already joined. - * @param {string} roomId The room to unban the user from. - * @param {string} target The target of the unban operation. - * @return {Promise} Resolved when unbanned, else rejected with an error. - */ -Intent.prototype.unban = function(roomId, target) { - var self = this; - return this._ensureJoined(roomId).then(function() { - return self.client.unban(roomId, target); - }); -}; - -/** - *

Join a room

- * This will automatically send an invite from the bot if it is an invite-only - * room, which may make the bot attempt to join the room if it isn't already. - * @param {string} roomId The room to join. - * @param {string[]} viaServers The server names to try and join through in - * addition to those that are automatically chosen. - * @return {Promise} - */ -Intent.prototype.join = function(roomId, viaServers) { - return this._ensureJoined(roomId, false, viaServers); -}; - -/** - *

Leave a room

- * This will no-op if the user isn't in the room. - * @param {string} roomId The room to leave. - * @return {Promise} - */ -Intent.prototype.leave = function(roomId) { - return this.client.leave(roomId); -}; - -/** - *

Get a user's profile information

- * @param {string} userId The ID of the user whose profile to return - * @param {string} info The profile field name to retrieve (e.g. 'displayname' - * or 'avatar_url'), or null to fetch the entire profile information. - * @param {boolean} [useCache=true] Should the request attempt to lookup - * state from the cache. - * @return {Promise} A Promise that resolves with the requested user's profile - * information - */ -Intent.prototype.getProfileInfo = function(userId, info, useCache=true) { - return this._ensureRegistered().then(() => { - if (useCache) { - return this._requestCaches.profile.get(`${userId}:${info}`, userId, info); - } - return this.client.getProfileInfo(userId, info); - }); -}; - -/** - *

Set the user's display name

- * @param {string} name The new display name - * @return {Promise} - */ -Intent.prototype.setDisplayName = function(name) { - var self = this; - return self._ensureRegistered().then(function() { - return self.client.setDisplayName(name); - }); -}; - -/** - *

Set the user's avatar URL

- * @param {string} url The new avatar URL - * @return {Promise} - */ -Intent.prototype.setAvatarUrl = function(url) { - var self = this; - return self._ensureRegistered().then(function() { - return self.client.setAvatarUrl(url); - }); -}; - -/** - * Create a new alias mapping. - * @param {string} alias The room alias to create - * @param {string} roomId The room ID the alias should point at. - * @return {Promise} - */ -Intent.prototype.createAlias = function(alias, roomId) { - var self = this; - return self._ensureRegistered().then(function() { - return self.client.createAlias(alias, roomId); - }); -}; - -/** - * Set the presence of this user. - * @param {string} presence One of "online", "offline" or "unavailable". - * @param {string} status_msg The status message to attach. - * @return {Promise} Resolves if the presence was set or no-oped, rejects otherwise. - */ -Intent.prototype.setPresence = function(presence, status_msg=undefined) { - if (!this.opts.enablePresence) { - return Promise.resolve(); - } - - return this._ensureRegistered().then(() => { - return this.client.setPresence({presence, status_msg}); - }); -}; - -/** - * @typedef { - * "m.event_not_handled" - * | "m.event_too_old" - * | "m.internal_error" - * | "m.foreign_network_error" - * | "m.event_unknown" - * } BridgeErrorReason - */ - -/** - * Signals that an error occured while handling an event by the bridge. - * - * **Warning**: This function is unstable and is likely to change pending the outcome - * of https://github.com/matrix-org/matrix-doc/pull/2162. - * @param {string} roomID ID of the room in which the error occured. - * @param {string} eventID ID of the event for which the error occured. - * @param {string} networkName Name of the bridged network. - * @param {BridgeErrorReason} reason The reason why the bridge error occured. - * @param {string} reason_body A human readable string d - * @param {string[]} affectedUsers Array of regex matching all affected users. - * @return {Promise} - */ -Intent.prototype.unstableSignalBridgeError = function( - roomID, - eventID, - networkName, - reason, - affectedUsers -) { - return this.sendEvent( - roomID, - "de.nasnotfound.bridge_error", - { - network_name: networkName, - reason: reason, - affected_users: affectedUsers, - "m.relates_to": { - rel_type: "m.reference", - event_id: eventID, - }, - } - ); -} - -/** - * Get an event in a room. - * This will automatically make the client join the room so they can get the - * event if they are not already joined. - * @param {string} roomId The room to fetch the event from. - * @param {string} eventId The eventId of the event to fetch. - * @param {boolean} [useCache=true] Should the request attempt to lookup from the cache. - * @return {Promise} Resolves with the content of the event, or rejects if not found. - */ -Intent.prototype.getEvent = function(roomId, eventId, useCache=true) { - return this._ensureRegistered().then(() => { - if (useCache) { - return this._requestCaches.event.get(`${roomId}:${eventId}`, roomId, eventId); - } - return this.client.fetchRoomEvent(roomId, eventId); - }); -}; - -/** - * Get a state event in a room. - * This will automatically make the client join the room so they can get the - * state if they are not already joined. - * @param {string} roomId The room to get the state from. - * @param {string} eventType The event type to fetch. - * @param {string} [stateKey=""] The state key of the event to fetch. - * @return {Promise} - */ -Intent.prototype.getStateEvent = function(roomId, eventType, stateKey = "") { - return this._ensureJoined(roomId).then(() => { - return this.client.getStateEvent(roomId, eventType, stateKey); - }); -}; - -/** - * Inform this Intent class of an incoming event. Various optimisations will be - * done if this is provided. For example, a /join request won't be sent out if - * it knows you've already been joined to the room. This function does nothing - * if a backing store was provided to the Intent. - * @param {Object} event The incoming event JSON - */ -Intent.prototype.onEvent = function(event) { - if (!this._membershipStates || !this._powerLevels) { - return; - } - if (event.type === "m.room.member" && - event.state_key === this.client.credentials.userId) { - this._membershipStates[event.room_id] = event.content.membership; - } - else if (event.type === "m.room.power_levels") { - this._powerLevels[event.room_id] = event.content; - } -}; - -// Guard a function which returns a promise which may reject if the user is not -// in the room. If the promise rejects, join the room and retry the function. -Intent.prototype._joinGuard = function(roomId, promiseFn) { - var self = this; - return function() { - return promiseFn().catch(function(err) { - if (err.errcode !== "M_FORBIDDEN") { - // not a guardable error - throw err; - } - return self._ensureJoined(roomId, true).then(function() { - return promiseFn(); - }) - }); - }; -}; - -Intent.prototype._ensureJoined = async function( - roomId, ignoreCache = false, viaServers = undefined, passthroughError = false -) { - const userId = this.client.credentials.userId; - const opts = { - syncRoom: false, - }; - if (viaServers) { - opts.viaServers = viaServers; - } - if (this.opts.backingStore.getMembership(roomId, userId) === "join" && !ignoreCache) { - return Promise.resolve(); - } - - /* Logic: - if client /join: - SUCCESS - else if bot /invite client: - if client /join: - SUCCESS - else: - FAIL (client couldn't join) - else if bot /join: - if bot /invite client and client /join: - SUCCESS - else: - FAIL (bot couldn't invite) - else: - FAIL (bot can't get into the room) - */ - - const deferredPromise = new defer(); - - const mark = (r, state) => { - this.opts.backingStore.setMembership(r, userId, state); - if (state === "join") { - deferredPromise.resolve(); - } - } - - const dontJoin = this.opts.dontJoin; - - try { - await this._ensureRegistered(); - if (dontJoin) { - deferredPromise.resolve(); - return deferredPromise.promise; - } - try { - await this.client.joinRoom(roomId, opts); - mark(roomId, "join"); - } - catch (ex) { - if (ex.errcode !== "M_FORBIDDEN" || this.botClient === this) { - throw ex; - } - try { - // Try bot inviting client - await this.botClient.invite(roomId, userId); - await this.client.joinRoom(roomId, opts); - mark(roomId, "join"); - } - catch (_ex) { - // Try bot joining - await this.botClient.joinRoom(roomId, opts) - await this.botClient.invite(roomId, userId); - await this.client.joinRoom(roomId, opts); - mark(roomId, "join"); - } - } - } - catch (ex) { - deferredPromise.reject(passthroughError ? ex : Error("Failed to join room")); - } - - return deferredPromise.promise; -}; - -Intent.prototype._ensureHasPowerLevelFor = function(roomId, eventType) { - if (this.opts.dontCheckPowerLevel && eventType !== "m.room.power_levels") { - return Promise.resolve(); - } - var self = this; - var userId = this.client.credentials.userId; - var plContent = this.opts.backingStore.getPowerLevelContent(roomId); - var promise = Promise.resolve(plContent); - if (!plContent) { - promise = this.client.getStateEvent(roomId, "m.room.power_levels", ""); - } - return promise.then(function(eventContent) { - self.opts.backingStore.setPowerLevelContent(roomId, eventContent); - const event = { - content: eventContent, - room_id: roomId, - sender: "", - event_id: "_", - state_key: "", - type: "m.room.power_levels" - } - var powerLevelEvent = new MatrixEvent(event); - // What level do we need for this event type? - var defaultLevel = event.content.events_default; - if (STATE_EVENT_TYPES.indexOf(eventType) !== -1) { - defaultLevel = event.content.state_default; - } - var requiredLevel = event.content.events[eventType] || defaultLevel; - - // Parse out what level the client has by abusing the JS SDK - var roomMember = new RoomMember(roomId, userId); - roomMember.setPowerLevelEvent(powerLevelEvent); - - if (requiredLevel > roomMember.powerLevel) { - // can the bot update our power level? - var bot = new RoomMember(roomId, self.botClient.credentials.userId); - bot.setPowerLevelEvent(powerLevelEvent); - var levelRequiredToModifyPowerLevels = event.content.events[ - "m.room.power_levels" - ] || event.content.state_default; - if (levelRequiredToModifyPowerLevels > bot.powerLevel) { - // even the bot has no power here.. give up. - throw new Error( - "Cannot ensure client has power level for event " + eventType + - " : client has " + roomMember.powerLevel + " and we require " + - requiredLevel + " and the bot doesn't have permission to " + - "edit the client's power level." - ); - } - // update the client's power level first - return self.botClient.setPowerLevel( - roomId, userId, requiredLevel, powerLevelEvent - ).then(function() { - // tweak the level for the client to reflect the new reality - var userLevels = powerLevelEvent.getContent().users || {}; - userLevels[userId] = requiredLevel; - powerLevelEvent.getContent().users = userLevels; - return Promise.resolve(powerLevelEvent); - }); - } - return Promise.resolve(powerLevelEvent); - }); -}; - -Intent.prototype._ensureRegistered = function() { - if (this.opts.registered) { - return Promise.resolve("registered=true"); - } - const userId = this.client.credentials.userId; - const localpart = new MatrixUser(userId).localpart; - return this.botClient.register(localpart).then((res) => { - this.opts.registered = true; - return res; - }).catch((err) => { - if (err.errcode === "M_USER_IN_USE") { - this.opts.registered = true; - return null; - } - throw err; - }); -}; - -module.exports = Intent; diff --git a/src/components/intent.ts b/src/components/intent.ts new file mode 100644 index 00000000..3c5a4af9 --- /dev/null +++ b/src/components/intent.ts @@ -0,0 +1,844 @@ +/* +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 MatrixUser from "../models/users/matrix"; +import JsSdk from "matrix-js-sdk"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { MatrixEvent, RoomMember } = JsSdk as any; +import ClientRequestCache from "./client-request-cache"; +import { defer } from "../utils/promiseutil"; + + +type BridgeErrorReason = "m.event_not_handled" | "m.event_too_old" + | "m.internal_error" | "m.foreign_network_error" | "m.event_unknown"; + +type MembershipState = "join" | "invite" | "leave" | null; // null = unknown + +type BackingStore = { + getMembership: (roomId: string, userId: string) => MembershipState, + getPowerLevelContent: (roomId: string) => Record | undefined, + setMembership: (roomId: string, userId: string, membership: MembershipState) => void, + setPowerLevelContent: (roomId: string, content: Record) => void, +}; + +interface IntentOpts { + backingStore?: BackingStore, + caching?: { + ttl?: number, + size?: number, + } + dontCheckPowerLevel?: boolean; + dontJoin?: boolean; + enablePresence?: boolean; + registered?: boolean; +} + +/** + * Returns the first parameter that is a number or 0. + */ +const returnFirstNumber = (...args: unknown[]) => { + for (const arg of args) { + if (typeof arg === "number") { + return arg; + } + } + return 0; +} + +const STATE_EVENT_TYPES = [ + "m.room.name", "m.room.topic", "m.room.power_levels", "m.room.member", + "m.room.join_rules", "m.room.history_visibility" +]; +const DEFAULT_CACHE_TTL = 90000; +const DEFAULT_CACHE_SIZE = 1024; + +type PowerLevelContent = { + // eslint-disable-next-line camelcase + state_default?: unknown; + // eslint-disable-next-line camelcase + events_default?: unknown; + // eslint-disable-next-line camelcase + users_default?: unknown; + users?: { + [userId: string]: unknown; + }, + events?: { + [eventType: string]: unknown; + } +}; + +export class Intent { + private _requestCaches: { + profile: ClientRequestCache, + roomstate: ClientRequestCache, + event: ClientRequestCache + } + private opts: { + backingStore: BackingStore, + caching: { + ttl: number, + size: number, + }; + dontCheckPowerLevel?: boolean; + dontJoin?: boolean; + enablePresence: boolean; + registered?: boolean; + } + // These two are only used if no opts.backingStore is provided to the constructor. + private readonly _membershipStates: Record = {}; + private readonly _powerLevels: Record = {}; + + /** + * Create an entity which can fulfil the intent of a given user. + * @constructor + * @param client The matrix client instance whose intent is being + * fulfilled e.g. the entity joining the room when you call intent.join(roomId). + * @param botClient The client instance for the AS bot itself. + * This will be used to perform more priveleged actions such as creating new + * rooms, sending invites, etc. + * @param opts Options for this Intent instance. + * @param opts.registered True to inform this instance that the client + * is already registered. No registration requests will be made from this Intent. + * Default: false. + * @param opts.dontCheckPowerLevel True to not check for the right power + * level before sending events. Default: false. + * + * @param opts.backingStore An object with 4 functions, outlined below. + * If this Object is supplied, ALL 4 functions must be supplied. If this Object + * is not supplied, the Intent will maintain its own backing store for membership + * and power levels, which may scale badly for lots of users. + * + * @param opts.backingStore.getMembership A function which is called with a + * room ID and user ID which should return the membership status of this user as + * a string e.g "join". `null` should be returned if the membership is unknown. + * + * @param opts.backingStore.getPowerLevelContent A function which is called + * with a room ID which should return the power level content for this room, as an Object. + * `null` should be returned if there is no known content. + * + * @param opts.backingStore.setMembership A function with the signature: + * function(roomId, userId, membership) which will set the membership of the given user in + * the given room. This has no return value. + * + * @param opts.backingStore.setPowerLevelContent A function with the signature: + * function(roomId, content) which will set the power level content in the given room. + * This has no return value. + * + * @param opts.dontJoin True to not attempt to join a room before + * sending messages into it. The surrounding code will have to ensure the correct + * membership state itself in this case. Default: false. + * + * @param opts.enablePresence True to send presence, false to no-op. + * + * @param opts.caching.ttl How long requests can stay in the cache, in milliseconds. + * @param opts.caching.size How many entries should be kept in the cache, before the oldest is dropped. + */ + constructor(public readonly client: any, private readonly botClient: any, opts: IntentOpts = {}) { + if (opts.backingStore) { + if (!opts.backingStore.setPowerLevelContent || + !opts.backingStore.getPowerLevelContent || + !opts.backingStore.setMembership || + !opts.backingStore.getMembership) { + throw new Error("Intent backingStore missing required functions"); + } + } + this.opts = { + ...opts, + backingStore: opts.backingStore ? { ...opts.backingStore } : { + getMembership: (roomId: string, userId: string) => { + if (userId !== this.client.credentials.userId) { + return null; + } + return this._membershipStates[roomId]; + }, + getPowerLevelContent: (roomId: string) => { + return this._powerLevels[roomId]; + }, + setMembership: (roomId: string, userId: string, membership: MembershipState) => { + if (userId !== this.client.credentials.userId) { + return; + } + this._membershipStates[roomId] = membership; + }, + setPowerLevelContent: (roomId: string, content: Record) => { + this._powerLevels[roomId] = content; + }, + }, + caching: { + size: opts.caching?.ttl || DEFAULT_CACHE_SIZE, + ttl: opts.caching?.ttl || DEFAULT_CACHE_TTL, + }, + enablePresence: opts.enablePresence !== false, + }; + this._requestCaches = { + profile: new ClientRequestCache( + this.opts.caching.ttl, + this.opts.caching.size, + (_: unknown, userId: string, info: string) => { + return this.getProfileInfo(userId, info, false); + } + ), + roomstate: new ClientRequestCache( + this.opts.caching.ttl, + this.opts.caching.size, + (roomId: string) => { + return this.roomState(roomId, false); + } + ), + event: new ClientRequestCache( + this.opts.caching.ttl, + this.opts.caching.size, + (_: unknown, roomId: string, eventId: string) => { + return this.getEvent(roomId, eventId, false); + } + ), + }; + } + + /** + * Return the client this Intent is acting on behalf of. + * @return The client + */ + public getClient() { + return this.client; + } + + /** + *

Send a plaintext message to a room.

+ * This will automatically make the client join the room so they can send the + * message if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param text The text string to send. + */ + public sendText(roomId: string, text: string) { + return this.sendMessage(roomId, { + body: text, + msgtype: "m.text" + }); + } + + /** + *

Set the name of a room.

+ * This will automatically make the client join the room so they can set the + * name if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param name The room name. + */ + public setRoomName(roomId: string, name: string) { + return this.sendStateEvent(roomId, "m.room.name", "", { + name: name + }); + } + + /** + *

Set the topic of a room.

+ * This will automatically make the client join the room so they can set the + * topic if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param topic The room topic. + */ + public setRoomTopic(roomId: string, topic: string) { + return this.sendStateEvent(roomId, "m.room.topic", "", { + topic: topic + }); + } + + /** + *

Set the avatar of a room.

+ * This will automatically make the client join the room so they can set the + * topic if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param avatar The url of the avatar. + * @param info Extra information about the image. See m.room.avatar for details. + */ + public setRoomAvatar(roomId: string, avatar: string, info?: string) { + const content = { + info, + url: avatar, + }; + return this.sendStateEvent(roomId, "m.room.avatar", "", content); + } + + /** + *

Send a typing event to a room.

+ * This will automatically make the client join the room so they can send the + * typing event if they are not already joined. + * @param roomId The room to send to. + * @param isTyping True if typing + */ + public async sendTyping(roomId: string, isTyping: boolean) { + await this._ensureJoined(roomId); + await this._ensureHasPowerLevelFor(roomId, "m.typing"); + return this.client.sendTyping(roomId, isTyping); + } + + /** + *

Send a read receipt to a room.

+ * This will automatically make the client join the room so they can send the + * receipt event if they are not already joined. + * @param roomId The room to send to. + * @param eventId The event ID to set the receipt mark to. + */ + public async sendReadReceipt(roomId: string, eventId: string) { + const event = new MatrixEvent({ + room_id: roomId, + event_id: eventId, + }); + await this._ensureJoined(roomId); + return this.client.sendReadReceipt(event); + } + + /** + * Set the power level of the given target. + * @param roomId The room to set the power level in. + * @param target The target user ID + * @param level The desired level. Undefined will remove the users custom power level. + */ + public async setPowerLevel(roomId: string, target: string, level: number|undefined) { + await this._ensureJoined(roomId); + const event = await this._ensureHasPowerLevelFor(roomId, "m.room.power_levels"); + return this.client.setPowerLevel(roomId, target, level, event); + } + + /** + *

Send an m.room.message event to a room.

+ * This will automatically make the client join the room so they can send the + * message if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param content The event content + */ + public sendMessage(roomId: string, content: Record) { + return this.sendEvent(roomId, "m.room.message", content); + } + + /** + *

Send a message event to a room.

+ * This will automatically make the client join the room so they can send the + * message if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param type The event type + * @param content The event content + */ + public async sendEvent(roomId: string, type: string, content: Record) { + await this._ensureJoined(roomId); + await this._ensureHasPowerLevelFor(roomId, type); + return this._joinGuard(roomId, async() => ( + this.client.sendEvent(roomId, type, content) + )); + } + + /** + *

Send a state event to a room.

+ * This will automatically make the client join the room so they can send the + * state if they are not already joined. It will also make sure that the client + * has sufficient power level to do this. + * @param roomId The room to send to. + * @param type The event type + * @param skey The state key + * @param content The event content + */ + public async sendStateEvent(roomId: string, type: string, skey: string, content: Record) { + await this._ensureJoined(roomId); + await this._ensureHasPowerLevelFor(roomId, type); + return this._joinGuard(roomId, async() => ( + this.client.sendStateEvent(roomId, type, content, skey) + )); + } + + /** + *

Get the current room state for a room.

+ * This will automatically make the client join the room so they can get the + * state if they are not already joined. + * @param roomId The room to get the state from. + * @param useCache Should the request attempt to lookup + * state from the cache. + */ + public async roomState(roomId: string, useCache=false) { + await this._ensureJoined(roomId); + if (useCache) { + return this._requestCaches.roomstate.get(roomId); + } + return this.client.roomState(roomId); + } + + /** + * Create a room with a set of options. + * @param opts Options. + * @param opts.createAsClient True to create this room as a client and + * not the bot: the bot will not join. False to create this room as the bot and + * auto-join the client. Default: false. + * @param opts.options Options to pass to the client SDK /createRoom API. + */ + public async createRoom(opts: { createAsClient?: boolean, options: Record}) { + const cli = opts.createAsClient ? this.client : this.botClient; + const options = opts.options || {}; + if (!opts.createAsClient) { + // invite the client if they aren't already + options.invite = options.invite || []; + if (Array.isArray(options.invite) && !options.invite.includes(this.client.credentials.userId)) { + options.invite.push(this.client.credentials.userId); + } + } + // make sure that the thing doing the room creation isn't inviting itself + // else Synapse hard fails the operation with M_FORBIDDEN + if (Array.isArray(options.invite) && options.invite.includes(cli.credentials.userId)) { + options.invite.splice(options.invite.indexOf(cli.credentials.userId), 1); + } + + await this._ensureRegistered(); + const res = await cli.createRoom(options); + if (typeof res !== "object" || !res) { + const type = res === null ? "null" : typeof res; + throw Error(`Expected Matrix Server to answer createRoom with an object, got ${type}.`); + } + const roomId = (res as Record).room_id; + if (typeof roomId !== "string") { + const type = typeof roomId; + throw Error(`Expected Matrix Server to answer createRoom with a room_id that is a string, got ${type}.`); + } + // create a fake power level event to give the room creator ops if we + // don't yet have a power level event. + if (this.opts.backingStore.getPowerLevelContent(roomId)) { + return res; + } + const users: Record = {}; + users[cli.credentials.userId] = 100; + this.opts.backingStore.setPowerLevelContent(roomId, { + users_default: 0, + events_default: 0, + state_default: 50, + users: users, + events: {} + }); + return res; + } + + /** + *

Invite a user to a room.

+ * This will automatically make the client join the room so they can send the + * invite if they are not already joined. + * @param roomId The room to invite the user to. + * @param target The user ID to invite. + * @return Resolved when invited, else rejected with an error. + */ + public async invite(roomId: string, target: string) { + await this._ensureJoined(roomId); + return this.client.invite(roomId, target); + } + + /** + *

Kick a user from a room.

+ * This will automatically make the client join the room so they can send the + * kick if they are not already joined. + * @param roomId The room to kick the user from. + * @param target The target of the kick operation. + * @param reason Optional. The reason for the kick. + * @return Resolved when kickked, else rejected with an error. + */ + public async kick(roomId: string, target: string, reason?: string) { + await this._ensureJoined(roomId); + return this.client.kick(roomId, target, reason); + } + + /** + *

Ban a user from a room.

+ * This will automatically make the client join the room so they can send the + * ban if they are not already joined. + * @param roomId The room to ban the user from. + * @param target The target of the ban operation. + * @param reason Optional. The reason for the ban. + * @return Resolved when banned, else rejected with an error. + */ + public async ban(roomId: string, target: string, reason?: string) { + await this._ensureJoined(roomId); + return this.client.ban(roomId, target, reason); + } + + /** + *

Unban a user from a room.

+ * This will automatically make the client join the room so they can send the + * unban if they are not already joined. + * @param roomId The room to unban the user from. + * @param target The target of the unban operation. + * @return Resolved when unbanned, else rejected with an error. + */ + public async unban(roomId: string, target: string) { + await this._ensureJoined(roomId); + return this.client.unban(roomId, target); + } + + /** + *

Join a room

+ * This will automatically send an invite from the bot if it is an invite-only + * room, which may make the bot attempt to join the room if it isn't already. + * @param roomId The room to join. + * @param viaServers The server names to try and join through in + * addition to those that are automatically chosen. + */ + public async join(roomId: string, viaServers?: string[]) { + await this._ensureJoined(roomId, false, viaServers); + } + + /** + *

Leave a room

+ * This will no-op if the user isn't in the room. + * @param roomId The room to leave. + */ + public async leave(roomId: string) { + return this.client.leave(roomId); + } + + /** + *

Get a user's profile information

+ * @param userId The ID of the user whose profile to return + * @param info The profile field name to retrieve (e.g. 'displayname' + * or 'avatar_url'), or null to fetch the entire profile information. + * @param useCache Should the request attempt to lookup + * state from the cache. + * @return A Promise that resolves with the requested user's profile + * information + */ + public async getProfileInfo(userId: string, info: string, useCache=true) { + await this._ensureRegistered(); + if (useCache) { + return this._requestCaches.profile.get(`${userId}:${info}`, userId, info); + } + return this.client.getProfileInfo(userId, info); + } + + /** + *

Set the user's display name

+ * @param name The new display name + */ + public async setDisplayName(name: string) { + await this._ensureRegistered(); + return this.client.setDisplayName(name); + } + + /** + *

Set the user's avatar URL

+ * @param url The new avatar URL + */ + public async setAvatarUrl(url: string) { + await this._ensureRegistered(); + return this.client.setAvatarUrl(url); + } + + /** + * Create a new alias mapping. + * @param alias The room alias to create + * @param roomId The room ID the alias should point at. + */ + public async createAlias(alias: string, roomId: string) { + await this._ensureRegistered(); + return this.client.createAlias(alias, roomId); + } + + /** + * Set the presence of this user. + * @param presence One of "online", "offline" or "unavailable". + * @param status_msg The status message to attach. + * @return Resolves if the presence was set or no-oped, rejects otherwise. + */ + // eslint-disable-next-line camelcase + public async setPresence(presence: string, status_msg?: string) { + if (!this.opts.enablePresence) { + return undefined; + } + + await this._ensureRegistered(); + return this.client.setPresence({presence, status_msg}); + } + + /** + * Signals that an error occured while handling an event by the bridge. + * + * **Warning**: This function is unstable and is likely to change pending the outcome + * of https://github.com/matrix-org/matrix-doc/pull/2162. + * @param roomID ID of the room in which the error occured. + * @param eventID ID of the event for which the error occured. + * @param networkName Name of the bridged network. + * @param reason The reason why the bridge error occured. + * @param reason_body A human readable string d + * @param affectedUsers Array of regex matching all affected users. + */ + public async unstableSignalBridgeError( + roomID: string, + eventID: string, + networkName: string, + reason: BridgeErrorReason, + affectedUsers: string[], + ) { + return this.sendEvent( + roomID, + "de.nasnotfound.bridge_error", + { + network_name: networkName, + reason: reason, + affected_users: affectedUsers, + "m.relates_to": { + rel_type: "m.reference", + event_id: eventID, + }, + } + ); + } + + /** + * Get an event in a room. + * This will automatically make the client join the room so they can get the + * event if they are not already joined. + * @param roomId The room to fetch the event from. + * @param eventId The eventId of the event to fetch. + * @param useCache Should the request attempt to lookup from the cache. + * @return Resolves with the content of the event, or rejects if not found. + */ + public async getEvent(roomId: string, eventId: string, useCache=true) { + await this._ensureRegistered(); + if (useCache) { + return this._requestCaches.event.get(`${roomId}:${eventId}`, roomId, eventId); + } + return this.client.fetchRoomEvent(roomId, eventId); + } + + /** + * Get a state event in a room. + * This will automatically make the client join the room so they can get the + * state if they are not already joined. + * @param roomId The room to get the state from. + * @param eventType The event type to fetch. + * @param [stateKey=""] The state key of the event to fetch. + */ + public async getStateEvent(roomId: string, eventType: string, stateKey = "") { + await this._ensureJoined(roomId); + return this.client.getStateEvent(roomId, eventType, stateKey); + } + + /** + * Inform this Intent class of an incoming event. Various optimisations will be + * done if this is provided. For example, a /join request won't be sent out if + * it knows you've already been joined to the room. This function does nothing + * if a backing store was provided to the Intent. + * @param event The incoming event JSON + */ + // eslint-disable-next-line camelcase + public onEvent(event: {type: string, content: {membership: MembershipState}, state_key: unknown, room_id: string}) { + if (!this._membershipStates || !this._powerLevels) { + return; + } + if (event.type === "m.room.member" && + event.state_key === this.client.credentials.userId) { + this._membershipStates[event.room_id] = event.content.membership; + } + else if (event.type === "m.room.power_levels") { + this._powerLevels[event.room_id] = event.content as unknown as PowerLevelContent; + } + } + + // Guard a function which returns a promise which may reject if the user is not + // in the room. If the promise rejects, join the room and retry the function. + private async _joinGuard(roomId: string, promiseFn: () => Promise) { + try { + // await so we can handle the error + return await promiseFn(); + } + catch (err) { + if (err.errcode !== "M_FORBIDDEN") { + // not a guardable error + throw err; + } + await this._ensureJoined(roomId, true); + return promiseFn(); + } + } + + private async _ensureJoined( + roomId: string, ignoreCache = false, viaServers?: string[], passthroughError = false + ) { + const { userId } = this.client.credentials; + const opts: { syncRoom: boolean, viaServers?: string[] } = { + syncRoom: false, + }; + if (viaServers) { + opts.viaServers = viaServers; + } + if (this.opts.backingStore.getMembership(roomId, userId) === "join" && !ignoreCache) { + return Promise.resolve(); + } + + /* Logic: + if client /join: + SUCCESS + else if bot /invite client: + if client /join: + SUCCESS + else: + FAIL (client couldn't join) + else if bot /join: + if bot /invite client and client /join: + SUCCESS + else: + FAIL (bot couldn't invite) + else: + FAIL (bot can't get into the room) + */ + + const deferredPromise = defer(); + + const mark = (room: string, state: MembershipState) => { + this.opts.backingStore.setMembership(room, userId, state); + if (state === "join") { + deferredPromise.resolve(); + } + } + + const dontJoin = this.opts.dontJoin; + + try { + await this._ensureRegistered(); + if (dontJoin) { + deferredPromise.resolve(); + return deferredPromise.promise; + } + try { + await this.client.joinRoom(roomId, opts); + mark(roomId, "join"); + } + catch (ex) { + if (ex.errcode !== "M_FORBIDDEN") { + throw ex; + } + try { + // Try bot inviting client + await this.botClient.invite(roomId, userId); + await this.client.joinRoom(roomId, opts); + mark(roomId, "join"); + } + catch (_ex) { + // Try bot joining + await this.botClient.joinRoom(roomId, opts) + await this.botClient.invite(roomId, userId); + await this.client.joinRoom(roomId, opts); + mark(roomId, "join"); + } + } + } + catch (ex) { + deferredPromise.reject(passthroughError ? ex : Error("Failed to join room")); + } + + return deferredPromise.promise; + } + + /** + * Ensures that the client has the required power level to post the event type. + * @param roomId Required as power levels exist inside a room. + * @param eventTypes The event type to check the permissions for. + * @return If found, the power level event + */ + private async _ensureHasPowerLevelFor(roomId: string, eventType: string) { + if (this.opts.dontCheckPowerLevel && eventType !== "m.room.power_levels") { + return undefined; + } + const userId = this.client.credentials.userId; + const plContent = this.opts.backingStore.getPowerLevelContent(roomId) + || await this.client.getStateEvent(roomId, "m.room.power_levels", ""); + const eventContent: PowerLevelContent = plContent && typeof plContent === "object" ? plContent : {}; + this.opts.backingStore.setPowerLevelContent(roomId, eventContent); + const event = { + content: typeof eventContent === "object" ? eventContent : {}, + room_id: roomId, + sender: "", + event_id: "_", + state_key: "", + type: "m.room.power_levels" + } + const powerLevelEvent = new MatrixEvent(event); + // What level do we need for this event type? + const defaultLevel = STATE_EVENT_TYPES.includes(eventType) + ? event.content.state_default + : event.content.events_default; + const requiredLevel = returnFirstNumber( + // If these are invalid or not provided, default to 0 according to the Spec. + // https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels + (event.content.events && event.content.events[eventType]), + defaultLevel, + 0 + ); + + + // Parse out what level the client has by abusing the JS SDK + const roomMember = new RoomMember(roomId, userId); + roomMember.setPowerLevelEvent(powerLevelEvent); + + if (requiredLevel > roomMember.powerLevel) { + // can the bot update our power level? + const bot = new RoomMember(roomId, this.botClient.credentials.userId); + bot.setPowerLevelEvent(powerLevelEvent); + const levelRequiredToModifyPowerLevels = returnFirstNumber( + // If these are invalid or not provided, default to 0 according to the Spec. + // https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels + event.content.events && event.content.events["m.room.power_levels"], + event.content.state_default, + 0 + ); + if (levelRequiredToModifyPowerLevels > bot.powerLevel) { + // even the bot has no power here.. give up. + throw new Error( + "Cannot ensure client has power level for event " + eventType + + " : client has " + roomMember.powerLevel + " and we require " + + requiredLevel + " and the bot doesn't have permission to " + + "edit the client's power level." + ); + } + // update the client's power level first + await this.botClient.setPowerLevel( + roomId, userId, requiredLevel, powerLevelEvent + ); + // tweak the level for the client to reflect the new reality + const userLevels = powerLevelEvent.getContent().users || {}; + userLevels[userId] = requiredLevel; + powerLevelEvent.getContent().users = userLevels; + } + return powerLevelEvent; + } + + private async _ensureRegistered() { + if (this.opts.registered) { + return "registered=true"; + } + const userId = this.client.credentials.userId; + const localpart = new MatrixUser(userId).localpart; + try { + const res = await this.botClient.register(localpart); + this.opts.registered = true; + return res; + } + catch (err) { + if (err.errcode === "M_USER_IN_USE") { + this.opts.registered = true; + return null; + } + throw err; + } + } +} diff --git a/src/components/request-factory.ts b/src/components/request-factory.ts index 40cdda33..676d45d1 100644 --- a/src/components/request-factory.ts +++ b/src/components/request-factory.ts @@ -22,7 +22,7 @@ type TimeoutFunction = (req: Request) => void; * A factory which can create {@link Request} objects. Useful for * adding "default" handlers to requests. */ -export class RequestFactory { +export class RequestFactory { private _resolves: HandlerFunction[] = []; private _rejects: HandlerFunction[] = []; private _timeouts: {fn: TimeoutFunction, timeout: number}[] = []; @@ -33,8 +33,8 @@ export class RequestFactory { * @param opts The options to pass to the Request constructor, if any. * @return A new request object */ - public newRequest(opts: RequestOpts) { - const req = new Request(opts); + public newRequest(opts?: RequestOpts) { + const req = new Request(opts || {data: null}); req.getPromise().then((res) => { this._resolves.forEach((resolveFn) => { resolveFn(req, res); diff --git a/src/components/state-lookup.ts b/src/components/state-lookup.ts index 7f604240..19f41248 100644 --- a/src/components/state-lookup.ts +++ b/src/components/state-lookup.ts @@ -18,7 +18,7 @@ import PQueue from "p-queue"; interface StateLookupOpts { // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any; //TODO: Needs to be MatrixClient (once that becomes TypeScript) - stateLookupConcurrency: number; + stateLookupConcurrency?: number; eventTypes?: string[]; retryStateInMs?: number; } @@ -33,7 +33,7 @@ interface StateLookupRoom { }; } -interface StateLookupEvent { +export interface StateLookupEvent { // eslint-disable-next-line camelcase room_id: string; // eslint-disable-next-line camelcase @@ -41,6 +41,7 @@ interface StateLookupEvent { type: string; // eslint-disable-next-line camelcase event_id: string; + content: unknown; } const RETRY_STATE_IN_MS = 300; @@ -96,7 +97,7 @@ export class StateLookup { * array of events, which may be empty. * @return {?Object|Object[]} */ - public getState(roomId: string, eventType: string, stateKey?: string): unknown|unknown[] { + public getState(roomId: string, eventType: string, stateKey?: string): null|StateLookupEvent|StateLookupEvent[] { const r = this.dict[roomId]; if (!r) { return stateKey === undefined ? [] : null; @@ -109,9 +110,7 @@ export class StateLookup { return es[eventType][stateKey] || null; } - return Object.keys(es[eventType]).map(function(skey) { - return es[eventType][skey]; - }); + return Object.keys(es[eventType]).map(skey => es[eventType][skey]); } private async getInitialState(roomId: string): Promise { @@ -121,12 +120,7 @@ export class StateLookup { () => this._client.roomState(roomId) ); events.forEach((ev) => { - if (this.eventTypes[ev.type]) { - if (!r.events[ev.type]) { - r.events[ev.type] = {}; - } - r.events[ev.type][ev.state_key] = ev; - } + this.insertEvent(r, ev); }); return r; } @@ -191,9 +185,22 @@ export class StateLookup { } // blunt update - if (!r.events[event.type]) { - r.events[event.type] = {}; + this.insertEvent(r, event); + } + + private insertEvent(roomSet: StateLookupRoom, event: StateLookupEvent) { + if (typeof event.content !== "object") { + // Reject - unexpected content type + return; + } + if (typeof event.type !== "string" || typeof event.state_key !== "string") { + // Reject - invalid keys + return; + } + // blunt update + if (!roomSet.events[event.type]) { + roomSet.events[event.type] = {}; } - r.events[event.type][event.state_key] = event; + roomSet.events[event.type][event.state_key] = event; } } diff --git a/src/index.ts b/src/index.ts index c7a4001d..88ac7c5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,12 +18,12 @@ export * from "./components/request"; export * from "./components/request-factory"; export * from "./components/client-factory"; +export * from "./components/intent"; +export * from "./components/state-lookup"; /* eslint-disable @typescript-eslint/no-var-requires */ -module.exports.Intent = require("./components/intent"); module.exports.AppServiceBot = require("./components/app-service-bot"); -module.exports.StateLookup = require("./components/state-lookup").StateLookup; // Config and CLI module.exports.Cli = require("./components/cli"); @@ -50,7 +50,7 @@ module.exports.AppServiceRegistration = ( const jsSdk = require("matrix-js-sdk"); -module.exports.ContentRepo = { +export const ContentRepo = { getHttpUriForMxc: jsSdk.getHttpUriForMxc, getIdenticonUri: jsSdk.getIdenticonUri, } diff --git a/tsconfig.json b/tsconfig.json index 68621ecf..6a59f0b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "commonjs", "allowJs": true, "checkJs": false, - "declaration": false, + "declaration": true, "sourceMap": true, "outDir": "./lib", "composite": false,