diff --git a/.eslintrc b/.eslintrc index 3eee4eac..f552ee29 100644 --- a/.eslintrc +++ b/.eslintrc @@ -53,7 +53,7 @@ "valid-typeof": 2, "array-bracket-spacing": [1, "never"], - "max-len": [1, 90], + "max-len": [1, 120], "brace-style": [1, "stroustrup", { "allowSingleLine": true }], "comma-spacing": [1, {"before": false, "after": true}], "comma-style": [1, "last"], diff --git a/README.md b/README.md index 7d711f60..efbd18ea 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,19 @@ If you set `opts.roomLinkValidation.triggerEndpoint` to `true`, then you may use optionally takes the `filename` parameter if you want to reload the config from another location. + +## `RoomUpgradeHandler` +This component automatically handles [Room Upgrades](https://matrix.org/docs/spec/client_server/unstable.html#post-matrix-client-r0-rooms-roomid-upgrade) +by changing all associated room entries to use the new room id as well as leaving +and joining ghosts. It can also be hooked into so you can manually adjust entries, +or do an action once the upgrade is over. + +This component is disabled by default but can enabled by simply defining `roomUpgradeOpts` +in the options given to the bridge (simply `{}` (empty object)). By default, users +will be copied on upgrade. Upgrade events will also be consumed by the bridge, and +will not be emitted by `onEvent`. For more information, see the docs. + + ## Data Models * `MatrixRoom` - A representation of a matrix room. * `RemoteRoom` - A representation of a third-party room. diff --git a/lib/bridge.js b/lib/bridge.js index 871f7d12..59a8e654 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -20,6 +20,7 @@ const util = require("util"); const MembershipCache = require("./components/membership-cache"); const RoomLinkValidator = require("./components/room-link-validator").RoomLinkValidator; const RLVStatus = require("./components/room-link-validator").validationStatuses; +const RoomUpgradeHandler = require("./components/room-upgrade-handler"); const log = require("./components/logging").get("bridge"); @@ -55,6 +56,9 @@ const INTENT_CULL_EVICT_AFTER_MS = 1000 * 60 * 15; // 15 minutes * @param {Bridge~thirdPartyLookup=} opts.controller.thirdPartyLookup Object. If * supplied, the bridge will respond to third-party entity lookups using the * contained helper functions. + * @param {Bridge~onRoomUpgrade=} opts.controller.onRoomUpgrade Function. If + * supplied, the bridge will invoke this function when it sees an upgrade event + * for a room. * @param {(RoomBridgeStore|string)=} opts.roomStore The room store instance to * use, or the path to the room .db file to load. A database will be created if * this is not specified. @@ -105,6 +109,8 @@ const INTENT_CULL_EVICT_AFTER_MS = 1000 * 60 * 15; // 15 minutes * @param {boolean=} opts.roomLinkValidation.triggerEndpoint Enable the endpoint * to trigger a reload of the rules file. * Default: false + * @param {RoomUpgradeHandler~Options} opts.roomUpgradeOpts Options to supply to + * the room upgrade handler. If not defined then upgrades are NOT handled by the bridge. */ function Bridge(opts) { if (typeof opts !== "object") { @@ -178,6 +184,13 @@ function Bridge(opts) { this._prevRequestPromise = Promise.resolve(); this._metrics = null; // an optional PrometheusMetrics instance this._roomLinkValidator = null; + if (opts.roomUpgradeOpts) { + this.opts.roomUpgradeOpts.consumeEvent = opts.roomUpgradeOpts.consumeEvent !== false ? true : false; + this._roomUpgradeHandler = new RoomUpgradeHandler(opts.roomUpgradeOpts, this); + } + else { + this._roomUpgradeHandler = null; + } } /** @@ -325,7 +338,8 @@ Bridge.prototype._customiseAppservice = function() { // one otherwised. this._roomLinkValidator.readRuleFile(req.query.filename); res.status(200).send("Success"); - } catch (e) { + } + catch (e) { res.status(500).send("Failed: " + e); } }, @@ -732,8 +746,25 @@ Bridge.prototype._onEvent = function(event) { this.opts.registration.isUserMatch(event.user_id, true)) { return Promise.resolve(); } + // m.room.tombstone is the event that signals a room upgrade. + if (event.type === "m.room.tombstone" && this._roomUpgradeHandler) { + this._roomUpgradeHandler.onTombstone(ev); + if (this.opts.RoomUpgradeHandler.consumeEvent) { + return Promise.resolve(); + } + } + else if (event.type === "m.room.member" && + event.state_key === this._appServiceBot.getUserId() && + event.content.membership === "invite") { + // A invite-only room that has been upgraded won't have been joinable, + // so we are listening for any invites to the new room. + const isUpgradeInvite = this._roomUpgradeHandler.onInvite(event); + if (isUpgradeInvite && + this.opts.RoomUpgradeHandler.consumeEvent) { + return Promise.resolve(); + } + } - var self = this; var request = this._requestFactory.newRequest({ data: event }); var context = new BridgeContext({ sender: event.user_id, @@ -755,10 +786,10 @@ Bridge.prototype._onEvent = function(event) { } if (this.opts.queue.type === "none") { // consume as soon as we have context - promise.done(function() { - self._onConsume(null, data); - }, function(err) { - self._onConsume(err); + promise.done(() => { + this._onConsume(null, data); + }, (err) => { + this._onConsume(err); }); return request.getPromise(); } @@ -1129,6 +1160,15 @@ BridgeContext.prototype.get = function(roomStore, userStore) { * @param {string} roomId The parsed localpart of the alias. */ + + /** + * @callback Bridge~onRoomUpgrade + * @param {string} oldRoomId The roomId of the old room. + * @param {string} newRoomId The roomId of the new room. + * @param {string} newVersion The new room version. + * @param {Bridge~BridgeContext} context Context for the upgrade event. + */ + /** * Invoked when the bridge receives an event from the homeserver. * @callback Bridge~onEvent diff --git a/lib/components/intent.js b/lib/components/intent.js index 7a1f2ecf..9bf9531b 100644 --- a/lib/components/intent.js +++ b/lib/components/intent.js @@ -51,9 +51,9 @@ const DEFAULT_CACHE_SIZE = 1024; * @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. */ @@ -445,10 +445,12 @@ Intent.prototype.unban = function(roomId, target) { * 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) { - return this._ensureJoined(roomId); +Intent.prototype.join = function(roomId, viaServers) { + return this._ensureJoined(roomId, false, viaServers); }; /** @@ -521,7 +523,7 @@ Intent.prototype.createAlias = function(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. + * @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) { @@ -535,10 +537,12 @@ Intent.prototype.setPresence = function(presence, status_msg=undefined) { /** * 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. + * @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(() => { @@ -549,6 +553,21 @@ Intent.prototype.getEvent = function(roomId, eventId, useCache=true) { }); }; +/** + * 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 @@ -586,9 +605,17 @@ Intent.prototype._joinGuard = function(roomId, promiseFn) { }; }; -Intent.prototype._ensureJoined = function(roomId, ignoreCache) { +Intent.prototype._ensureJoined = function( + roomId, ignoreCache = false, viaServers = undefined, passthroughError = false +) { var self = this; var userId = self.client.credentials.userId; + const opts = { + syncRoom: false, + }; + if (viaServers) { + opts.viaServers = viaServers; + } if (this.opts.backingStore.getMembership(roomId, userId) === "join" && !ignoreCache) { return Promise.resolve(); } @@ -627,28 +654,30 @@ Intent.prototype._ensureJoined = function(roomId, ignoreCache) { return; } - self.client.joinRoom(roomId, { syncRoom: false }).then(function() { + self.client.joinRoom(roomId, opts).then(function() { mark(roomId, "join"); }, function(e) { - if (e.errcode !== "M_FORBIDDEN") { - d.reject(new Error("Failed to join room")); + if (e.errcode !== "M_FORBIDDEN" || self.botClient === self) { + d.reject(passthroughError ? e : new Error("Failed to join room")); return; } + // Try bot inviting client self.botClient.invite(roomId, userId).then(function() { - return self.client.joinRoom(roomId, { syncRoom: false }); + return self.client.joinRoom(roomId, opts); }).done(function() { mark(roomId, "join"); }, function(invErr) { // Try bot joining - self.botClient.joinRoom(roomId, { syncRoom: false }).then(function() { + self.botClient.joinRoom(roomId, opts) + .then(function() { return self.botClient.invite(roomId, userId); }).then(function() { - return self.client.joinRoom(roomId, { syncRoom: false }); + return self.client.joinRoom(roomId, opts); }).done(function() { mark(roomId, "join"); }, function(finalErr) { - d.reject(new Error("Failed to join room")); + d.reject(passthroughError ? e : new Error("Failed to join room")); return; }); }); diff --git a/lib/components/room-upgrade-handler.js b/lib/components/room-upgrade-handler.js new file mode 100644 index 00000000..1c5cd3ad --- /dev/null +++ b/lib/components/room-upgrade-handler.js @@ -0,0 +1,153 @@ +const log = require("./logging").get("RoomUpgradeHandler"); +const MatrixRoom = require("../models/rooms/matrix"); +const MatrixUser = require("../models/users/matrix"); + +/** + * Handles migration of rooms when a room upgrade is performed. + */ +class RoomUpgradeHandler { + /** + * @param {RoomUpgradeHandler~Options} opts + * @param {Bridge} bridge The parent bridge. + */ + constructor(opts, bridge) { + if (opts.migrateGhosts !== false) { + opts.migrateGhosts = opts.migrateGhosts !== false; + } + this._opts = opts; + this._bridge = bridge; + this._waitingForInvite = new Map(); //newRoomId: oldRoomId + } + + onTombstone(ev) { + const movingTo = ev.content.replacement_room; + log.info(`Got tombstone event for ${ev.room_id} -> ${movingTo}`); + const joinVia = new MatrixUser(ev.sender).domain; + // Try to join the new room. + return this._joinNewRoom(movingTo, [joinVia]).then((couldJoin) => { + if (couldJoin) { + return this._onJoinedNewRoom(ev.room_id, movingTo); + } + this._waitingForInvite.set(movingTo, ev.room_id); + return true; + }).catch((err) => { + log.error("Couldn't handle room upgrade: ", err); + return false; + }); + } + + _joinNewRoom(newRoomId, joinVia=[]) { + const intent = this._bridge.getIntent(); + return intent.join(newRoomId, [joinVia]).then(() => { + return true; + }).catch((ex) => { + if (ex.errcode === "M_FORBIDDEN") { + return false; + } + throw Error("Failed to handle upgrade"); + }) + } + + onInvite(ev) { + if (!this._waitingForInvite.has(ev.room_id)) { + return false; + } + const oldRoomId = this._waitingForInvite.get(ev.room_id); + this._waitingForInvite.delete(ev.room_id); + log.debug(`Got invite to upgraded room ${ev.room_id}`); + this._joinNewRoom(ev.room_id).then(() => { + return this._onJoinedNewRoom(oldRoomId, ev.room_id); + }).catch((err) => { + log.error("Couldn't handle room upgrade: ", err); + }); + return true; + } + + _onJoinedNewRoom(oldRoomId, newRoomId) { + log.debug(`Joined ${newRoomId}`); + const intent = this._bridge.getIntent(); + const asBot = this.bridge.getBot(); + return this.getRoomStore().getEntriesByMatrixId((entries) => { + return entries.map((entry) => { + const newEntry = ( + this._opts.migrateEntry || this._migrateEntry)(entry, newRoomId); + if (!newEntry) { + return Promise.resolve(); + } + return this.getRoomStore().upsertEntry(newEntry); + }); + }).catch((ex) => { + log.warn("Failed to migrate room entries:", ex); + }).then(() => { + log.debug(`Migrated entries from ${oldRoomId} to ${newRoomId} successfully.`); + if (this._opts.onRoomMigrated) { + this._opts.onRoomMigrated(oldRoomId, newRoomId); + } + + if (!this.opts.migrateGhosts) { + return Promise.resolve(); + } + return asBot.getJoinedMembers(oldRoomId); + }).then((members) => { + const userIds = Object.keys(members).filter((u) => asBot.isRemoteUser(u)); + log.debug(`Migrating ${userIds.length} ghosts`); + return Promise.all(userIds.map((uId) => { + Promise.all([ + this.bridge.getIntent(userId).leave(oldRoomId), + this.bridge.getIntent(userId).join(newRoomId) + ]) + }).concat([ + intent.leave(oldRoomId) + ])); + }).catch((ex) => { + log.warn("Failed to migrate room ghosts:", ex); + }).then(() => { + log.debug("Migrated all ghosts across"); + }) + } + + _migrateEntry(entry, newRoomId) { + entry.matrix = new MatrixRoom(newRoomId, { + name: entry.name, + topic: entry.topic, + extras: entry._extras, + }); + return entry; + } +} + +module.exports = RoomUpgradeHandler; + + +/** + * Invoked when iterating around a rooms entries. Should be used to update entries + * with a new room id. + * @callback RoomUpgradeHandler~Options~MigrateEntry + * @param {RoomBridgeStore~Entry} entry The existing entry. + * @param {string} newRoomId The new roomId. + * @return {RoomBridgeStore~Entry|null} Return the entry to upsert it, + * or null to ignore it. + */ + + /** + * Invoked after a room has been upgraded and it's entries updated. + * @callback RoomUpgradeHandler~Options~onRoomMigrated + * @param {string} oldRoomId The old roomId. + * @param {string} newRoomId The new roomId. + */ + + /** + * Options to supply to the {@link RoomUpgradeHandler}. + * @typedef RoomUpgradeHandler~Options + * @type {Object} + * @property {RoomUpgradeHandler~Options~MigrateEntry} migrateEntry Called when + * the handler wishes to migrate a MatrixRoom entry to a new room_id. If omitted, + * {@link RoomUpgradeHandler~_migrateEntry} will be used instead. + * @property {RoomUpgradeHandler~Options~onRoomMigrated} onRoomMigrated This is called + * when the entries of the room have been migrated, the bridge should do any cleanup it + * needs of the old room and setup the new room (ex: Joining ghosts to the new room). + * @property {bool} [consumeEvent=true] Consume tombstone or invite events that + * are acted on by this handler. + * @property {bool} [migrateGhosts=true] If given, migrate all ghost users across to + * the new room. + */ diff --git a/package.json b/package.json index 0d81da08..cc8ec7ea 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "is-my-json-valid": "^2.19.0", "js-yaml": "^3.12.0", "matrix-appservice": "0.3.5", - "matrix-js-sdk": "^0.10.9", + "matrix-js-sdk": "^1.0.1", "nedb": "^1.1.3", "nopt": "^3.0.3", "prom-client": "^11.1.1", @@ -39,8 +39,7 @@ "eslint": "^1.2.0", "istanbul": "^0.4.5", "jasmine": "^2.5.2", - "jsdoc": "^3.3.2", - "eslint": "^1.2.0" + "jsdoc": "^3.3.2" }, "optionalDependencies": { "winston": "^3.1.0", diff --git a/spec/unit/room-upgrade-handler.spec.js b/spec/unit/room-upgrade-handler.spec.js new file mode 100644 index 00000000..9ca734eb --- /dev/null +++ b/spec/unit/room-upgrade-handler.spec.js @@ -0,0 +1,139 @@ +const RoomUpgradeHandler = require("../../lib/components/room-upgrade-handler") + +describe("RoomLinkValidator", () => { + describe("constructor", () => { + it("should construct", () => { + const ruh = new RoomUpgradeHandler({isOpts: true}, {isBridge: true}); + expect(ruh._opts).toEqual({isOpts: true, migrateGhosts: true}); + expect(ruh._bridge).toEqual({isBridge: true}); + expect(ruh._waitingForInvite.size).toEqual(0); + }); + }); + describe("onTombstone", () => { + it("should join the new room", () => { + let joined; + const bridge = { + getIntent: () => ({ + join: (room_id) => { joined = room_id; return Promise.resolve(); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + ruh._onJoinedNewRoom = () => true; + return ruh.onTombstone({ + room_id: "!abc:def", + sender: "@foo:bar", + content: { + replacement_room: "!new:def", + } + }).then((res) => { + expect(joined).toEqual("!new:def"); + expect(ruh._waitingForInvite.size).toEqual(0); + expect(res).toEqual(true); + }); + }); + it("should wait for an invite on M_FORBIDDEN", () => { + let joined; + const bridge = { + getIntent: () => ({ + join: (room_id) => { joined = room_id; return Promise.reject({errcode: "M_FORBIDDEN"}); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + return ruh.onTombstone({ + room_id: "!abc:def", + sender: "@foo:bar", + content: { + replacement_room: "!new:def", + } + }).then((res) => { + expect(joined).toEqual("!new:def"); + expect(ruh._waitingForInvite.size).toEqual(1); + expect(res).toEqual(true); + }); + }); + it("should do nothing on failure", () => { + let joined; + const bridge = { + getIntent: () => ({ + join: (room_id) => { joined = room_id; return Promise.reject({}); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + ruh._onJoinedNewRoom = () => true; + return ruh.onTombstone({ + room_id: "!abc:def", + sender: "@foo:bar", + content: { + replacement_room: "!new:def", + } + }).then((res) => { + expect(joined).toEqual("!new:def"); + expect(ruh._waitingForInvite.size).toEqual(0); + expect(res).toEqual(false); + }); + }); + }); + describe("_joinNewRoom", () => { + it("should join a room successfully", () => { + let joined; + const bridge = { + getIntent: () => ({ + join: (room_id) => { joined = room_id; return Promise.resolve({}); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + return ruh._joinNewRoom("!new:def", "!new:def").then((res) => { + expect(res).toEqual(true); + expect(joined).toEqual("!new:def"); + }); + }); + it("should return false on M_FORBIDDEN", () => { + let joined; + const bridge = { + getIntent: () => ({ + join: (room_id) => { joined = room_id; return Promise.reject({errcode: "M_FORBIDDEN"}); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + return ruh._joinNewRoom("!new:def").then((res) => { + expect(joined).toEqual("!new:def"); + expect(res).toEqual(false); + }); + }); + it("should fail for any other reason", () => { + const bridge = { + getIntent: () => ({ + join: (room_id) => { return Promise.reject({}); }, + }), + }; + const ruh = new RoomUpgradeHandler({}, bridge); + return ruh._joinNewRoom("!new:def", "!new:def").catch((err) => { + expect(err.message).toEqual("Failed to handle upgrade"); + }); + }); + }); + describe("onInvite", () => { + it("should not handle a unexpected invite", () => { + const ruh = new RoomUpgradeHandler({}, {}); + expect(ruh.onInvite({ + room_id: "!abc:def", + })).toEqual(false); + }); + it("should handle a expected invite", (done) => { + const ruh = new RoomUpgradeHandler({}, {}); + let newRoomId = false; + ruh._waitingForInvite.set("!new:def", "!abc:def"); + ruh._joinNewRoom = (_newRoomId) => { + newRoomId = _newRoomId; + return Promise.resolve(); + } + ruh._onJoinedNewRoom = () => { + expect(newRoomId).toEqual("!new:def"); + done(); + } + expect(ruh.onInvite({ + room_id: "!new:def", + })).toEqual(true); + }); + }); +});