Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support room upgrades #100

Merged
merged 14 commits into from
Mar 14, 2019
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
51 changes: 45 additions & 6 deletions lib/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -178,6 +184,12 @@ 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;
}
}

/**
Expand Down Expand Up @@ -325,7 +337,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);
}
},
Expand Down Expand Up @@ -732,8 +745,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" &&
turt2live marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand All @@ -755,10 +785,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();
}
Expand Down Expand Up @@ -1129,6 +1159,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
Expand Down
57 changes: 43 additions & 14 deletions lib/components/intent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
};

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
});
});
Expand Down
126 changes: 126 additions & 0 deletions lib/components/room-upgrade-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are they?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They should be described here in the jsdoc if we expect people to create this object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not describing them 3 times, people have the ability to click on hyperlinks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fair, I thought this was much higher in the stack. Do we even need jsdoc here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, otherwise we don't know the type that we are passing.

* @param {Bridge} bridge The parent bridge.
*/
constructor(opts, bridge) {
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();
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);
});
}).then(() => {
log.debug(`Migrated entries from ${oldRoomId} to ${newRoomId} successfully.`);
if (this._opts.onRoomMigrated) {
this._opts.onRoomMigrated(oldRoomId, newRoomId);
}
return intent.leave(oldRoomId);
});
}

_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.
*/
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading