diff --git a/.eslintrc.json b/.eslintrc.json
index cf35ae93..3c996507 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,78 +1,85 @@
{
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["@typescript-eslint"],
"parserOptions": {
- "ecmaVersion": 2018
+ "ecmaVersion": 9,
+ "ecmaFeatures": {
+ "jsx": false
+ }
},
"env": {
"node": true,
- "jasmine": true,
- "es6": true
+ "jasmine": true
},
+ "extends": ["plugin:@typescript-eslint/recommended"],
"rules": {
- // 0: off, 1: warn, 2: error
- "consistent-return": 2,
- "curly": 1,
- "default-case": 2,
- "guard-for-in": 2,
-
- "no-alert": 2,
- "no-caller": 2,
- "no-cond-assign": 2,
- "no-constant-condition": 2,
- "no-debugger": 2,
- "no-dupe-args": 2,
- "no-dupe-keys": 2,
- "no-duplicate-case": 2,
- "no-else-return": 2,
- "no-empty": 2,
- "no-empty-character-class": 2,
- "no-eq-null": 2,
- "no-ex-assign": 2,
- "no-extend-native": 2,
- "no-extra-boolean-cast": 2,
- "no-extra-semi": 1,
- "no-fallthrough": 2,
- "no-func-assign": 2,
- "no-invalid-regexp": 2,
- "no-invalid-this": 2,
- "no-irregular-whitespace": 1,
- "no-lone-blocks": 2,
- "no-loop-func": 2,
- "no-multi-spaces": 1,
- "no-new-wrappers": 2,
- "no-new": 2,
- "no-octal": 2,
- "no-negated-in-lhs": 2,
- "no-obj-calls": 2,
- "no-redeclare": 2,
- "no-regex-spaces": 2,
- "no-return-assign": 2,
- "no-self-compare": 2,
- "no-shadow": 2,
- "no-unreachable": 2,
- "no-unexpected-multiline": 2,
- "no-unused-expressions": 2,
- "no-unused-vars": [2, {"vars": "all", "args": "none"}],
- "no-use-before-define": [1, "nofunc"],
- "use-isnan": 2,
- "valid-typeof": 2,
-
- "array-bracket-spacing": [1, "never"],
- "max-len": [1, 120],
- "brace-style": [1, "stroustrup", { "allowSingleLine": true }],
- "comma-spacing": [1, {"before": false, "after": true}],
- "comma-style": [1, "last"],
- "computed-property-spacing": [1, "never"],
- "consistent-this": [1, "self"],
- "eol-last": 1,
- "linebreak-style": [1, "unix"],
- "new-cap": 0,
- "new-parens": 1,
- "no-mixed-spaces-and-tabs": 1,
- "no-nested-ternary": 1,
- "no-spaced-func": 1,
- "no-trailing-spaces": 1,
- "keyword-spacing": [1, {"after": true}],
- "space-before-blocks": [1, "always"],
- "camelcase": [1, {"properties": "never", "ignoreDestructuring": true}]
+ "consistent-return": "error",
+ "curly": "error",
+ "default-case": "error",
+ "guard-for-in": "error",
+ "no-alert": "error",
+ "no-caller": "error",
+ "no-cond-assign": "error",
+ "no-constant-condition": "error",
+ "no-debugger": "error",
+ "no-dupe-args": "error",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-else-return": "error",
+ "no-empty": "error",
+ "no-empty-character-class": "error",
+ "no-eq-null": "error",
+ "no-ex-assign": "error",
+ "no-extend-native": "error",
+ "no-extra-boolean-cast": "error",
+ "no-extra-semi": "error",
+ "no-fallthrough": "error",
+ "no-func-assign": "error",
+ "no-invalid-regexp": "error",
+ "no-invalid-this": "error",
+ "no-irregular-whitespace": "error",
+ "no-lone-blocks": "error",
+ "no-loop-func": "error",
+ "no-multi-spaces": "error",
+ "no-new-wrappers": "error",
+ "no-new": "error",
+ "no-octal": "error",
+ "no-negated-in-lhs": "error",
+ "no-obj-calls": "error",
+ "no-redeclare": "error",
+ "no-regex-spaces": "error",
+ "no-return-assign": "error",
+ "no-self-compare": "error",
+ "no-shadow": "error",
+ "no-unreachable": "error",
+ "no-unexpected-multiline": "error",
+ "no-unused-expressions": "error",
+ "no-use-before-define": "off",
+ "use-isnan": "error",
+ "valid-typeof": "error",
+ "array-bracket-spacing": ["error", "never"],
+ "max-len": ["error", 120],
+ "brace-style": ["error", "stroustrup", { "allowSingleLine": true }],
+ "comma-spacing": ["error", {"before": false, "after": true}],
+ "comma-style": ["error", "last"],
+ "computed-property-spacing": ["error", "never"],
+ "consistent-this": ["error", "self"],
+ "eol-last": "error",
+ "new-cap": "error",
+ "new-parens": "error",
+ "no-mixed-spaces-and-tabs": "error",
+ "no-nested-ternary": "error",
+ "no-spaced-func": "error",
+ "no-trailing-spaces": "error",
+ "keyword-spacing": ["error", {"before": true, "after": true}],
+ "space-before-blocks": ["error", "always"],
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "camelcase": ["error", { "properties": "never" }],
+ "@typescript-eslint/ban-ts-ignore": "off",
+ "no-unused-vars": "off",
+ "strict": ["error", "never" ],
+ "no-var": "error",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/no-explicit-any": "warn"
}
-}
+}
\ No newline at end of file
diff --git a/.eslintrcts.json b/.eslintrcts.json
deleted file mode 100644
index 3c996507..00000000
--- a/.eslintrcts.json
+++ /dev/null
@@ -1,85 +0,0 @@
-{
- "parser": "@typescript-eslint/parser",
- "plugins": ["@typescript-eslint"],
- "parserOptions": {
- "ecmaVersion": 9,
- "ecmaFeatures": {
- "jsx": false
- }
- },
- "env": {
- "node": true,
- "jasmine": true
- },
- "extends": ["plugin:@typescript-eslint/recommended"],
- "rules": {
- "consistent-return": "error",
- "curly": "error",
- "default-case": "error",
- "guard-for-in": "error",
- "no-alert": "error",
- "no-caller": "error",
- "no-cond-assign": "error",
- "no-constant-condition": "error",
- "no-debugger": "error",
- "no-dupe-args": "error",
- "no-dupe-keys": "error",
- "no-duplicate-case": "error",
- "no-else-return": "error",
- "no-empty": "error",
- "no-empty-character-class": "error",
- "no-eq-null": "error",
- "no-ex-assign": "error",
- "no-extend-native": "error",
- "no-extra-boolean-cast": "error",
- "no-extra-semi": "error",
- "no-fallthrough": "error",
- "no-func-assign": "error",
- "no-invalid-regexp": "error",
- "no-invalid-this": "error",
- "no-irregular-whitespace": "error",
- "no-lone-blocks": "error",
- "no-loop-func": "error",
- "no-multi-spaces": "error",
- "no-new-wrappers": "error",
- "no-new": "error",
- "no-octal": "error",
- "no-negated-in-lhs": "error",
- "no-obj-calls": "error",
- "no-redeclare": "error",
- "no-regex-spaces": "error",
- "no-return-assign": "error",
- "no-self-compare": "error",
- "no-shadow": "error",
- "no-unreachable": "error",
- "no-unexpected-multiline": "error",
- "no-unused-expressions": "error",
- "no-use-before-define": "off",
- "use-isnan": "error",
- "valid-typeof": "error",
- "array-bracket-spacing": ["error", "never"],
- "max-len": ["error", 120],
- "brace-style": ["error", "stroustrup", { "allowSingleLine": true }],
- "comma-spacing": ["error", {"before": false, "after": true}],
- "comma-style": ["error", "last"],
- "computed-property-spacing": ["error", "never"],
- "consistent-this": ["error", "self"],
- "eol-last": "error",
- "new-cap": "error",
- "new-parens": "error",
- "no-mixed-spaces-and-tabs": "error",
- "no-nested-ternary": "error",
- "no-spaced-func": "error",
- "no-trailing-spaces": "error",
- "keyword-spacing": ["error", {"before": true, "after": true}],
- "space-before-blocks": ["error", "always"],
- "@typescript-eslint/explicit-function-return-type": "off",
- "camelcase": ["error", { "properties": "never" }],
- "@typescript-eslint/ban-ts-ignore": "off",
- "no-unused-vars": "off",
- "strict": ["error", "never" ],
- "no-var": "error",
- "@typescript-eslint/explicit-module-boundary-types": "off",
- "@typescript-eslint/no-explicit-any": "warn"
- }
-}
\ No newline at end of file
diff --git a/changelog.d/213.misc b/changelog.d/213.misc
new file mode 100644
index 00000000..1059c8e9
--- /dev/null
+++ b/changelog.d/213.misc
@@ -0,0 +1 @@
+Port the `Bridge` object to Typescript and remove javascript linting
\ No newline at end of file
diff --git a/package.json b/package.json
index 7fb91e66..43ca5a6f 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,8 @@
"build": "tsc --project tsconfig.json",
"prepare": "npm run build",
"gendoc": "jsdoc -r lib -R README.md -P package.json -d .jsdoc",
- "lint": "npm run lint:js && npm run lint:ts",
- "lint:js": "eslint -c .eslintrc.json src/**/*.js spec/**/*.js",
- "lint:ts": "eslint -c .eslintrcts.json src/**/*.ts",
+ "lint": "npm run lint:ts",
+ "lint:ts": "eslint -c .eslintrc.json src/**/*.ts",
"test": "BLUEBIRD_DEBUG=1 jasmine --stop-on-failure=true",
"check": "npm run lint && npm test",
"ci-test": "BLUEBIRD_DEBUG=1 nyc -x \"**/spec/**\" --report text jasmine"
diff --git a/spec/integ/bridge.spec.js b/spec/integ/bridge.spec.js
index 0d3b664b..59570a7e 100644
--- a/spec/integ/bridge.spec.js
+++ b/spec/integ/bridge.spec.js
@@ -110,8 +110,10 @@ describe("Bridge", function() {
controller: bridgeCtrl,
clientFactory: clientFactory
});
+ return bridge.loadDatabases();
+ }).then(() => {
done();
- }).done();
+ });
});
afterEach(function() {
@@ -185,7 +187,7 @@ describe("Bridge", function() {
});
});
- it("should provision the room from the returned object", async() => {
+ it("should provision the room from the returned object", async() => {
const provisionedRoom = {
creationOpts: {
room_alias_name: "foo",
@@ -271,7 +273,7 @@ describe("Bridge", function() {
bridge.run(101, {}, appService).then(function() {
return appService.emit("event", event);
- }).done(function() {
+ }).then(function() {
expect(bridgeCtrl.onEvent).toHaveBeenCalled();
var call = bridgeCtrl.onEvent.calls.argsFor(0);
var req = call[0];
@@ -357,7 +359,7 @@ describe("Bridge", function() {
);
}).then(function() {
return appService.emit("event", event);
- }).done(function() {
+ }).then(function() {
expect(bridgeCtrl.onEvent).toHaveBeenCalled();
var call = bridgeCtrl.onEvent.calls.argsFor(0);
var req = call[0];
@@ -406,11 +408,11 @@ describe("Bridge", function() {
describe("run", () => {
it("should invoke listen(port) on the AppService instance", async() => {
await bridge.run(101, {}, appService);
- expect(appService.listen).toHaveBeenCalledWith(101, undefined);
+ expect(appService.listen).toHaveBeenCalledWith(101, "0.0.0.0", 10);
});
it("should invoke listen(port, hostname) on the AppService instance", async() => {
await bridge.run(101, {}, appService, "foobar");
- expect(appService.listen).toHaveBeenCalledWith(101, "foobar");
+ expect(appService.listen).toHaveBeenCalledWith(101, "foobar", 10);
});
});
@@ -569,7 +571,7 @@ describe("Bridge", function() {
describe("provisionUser", function() {
beforeEach(function(done) {
- bridge.run(101, {}, appService).done(function() {
+ bridge.run(101, {}, appService).then(function() {
done();
});
});
@@ -634,7 +636,7 @@ describe("Bridge", function() {
bridge.provisionUser(mxUser, provisionedUser).then(function() {
expect(botClient.register).toHaveBeenCalledWith(mxUser.localpart);
return bridge.getUserStore().getRemoteUsersFromMatrixId("@foo:bar");
- }).done(function(users) {
+ }).then(function(users) {
expect(users.length).toEqual(1);
if (users.length > 0) {
expect(users[0].getId()).toEqual("__remote__");
@@ -662,64 +664,64 @@ describe("Bridge", function() {
describe("_onEvent", () => {
it("should not upgrade a room if state_key is not defined", () => {
- bridge._roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
- bridge._roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
+ bridge.roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
+ bridge.roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
bridgeCtrl.onEvent.and.callFake(function(req) { req.resolve(); });
bridge.opts.roomUpgradeOpts = { consumeEvent: true };
return bridge.run(101, {}, appService).then(() => {
- return bridge._onEvent({
+ return bridge.onEvent({
type: "m.room.tombstone",
state_key: undefined,
sender: "@foo:bar",
});
}).then(() => {
- expect(bridge._roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
+ expect(bridge.roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
});
});
it("should not upgrade a room if state_key is not === '' ", () => {
- bridge._roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
- bridge._roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
+ bridge.roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
+ bridge.roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
bridgeCtrl.onEvent.and.callFake(function(req) { req.resolve(); });
bridge.opts.roomUpgradeOpts = { consumeEvent: true };
return bridge.run(101, {}, appService).then(() => {
- return bridge._onEvent({
+ return bridge.onEvent({
type: "m.room.tombstone",
state_key: "fooobar",
sender: "@foo:bar",
});
}).then(() => {
- expect(bridge._roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
- return bridge._onEvent({
+ expect(bridge.roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
+ return bridge.onEvent({
type: "m.room.tombstone",
state_key: 212345,
sender: "@foo:bar",
});
}).then(() => {
- expect(bridge._roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
- return bridge._onEvent({
+ expect(bridge.roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
+ return bridge.onEvent({
type: "m.room.tombstone",
state_key: null,
sender: "@foo:bar",
});
}).then(() => {
- expect(bridge._roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
+ expect(bridge.roomUpgradeHandler.onTombstone).not.toHaveBeenCalled();
});
});
it("should upgrade a room if state_key == '' is defined", () => {
- bridge._roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
- bridge._roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
+ bridge.roomUpgradeHandler = jasmine.createSpyObj("_roomUpgradeHandler", ["onTombstone"]);
+ bridge.roomUpgradeHandler.onTombstone.and.returnValue(Promise.resolve({}));
bridgeCtrl.onEvent.and.callFake(function(req) { req.resolve(); });
bridge.opts.roomUpgradeOpts = { consumeEvent: true };
return bridge.run(101, {}, appService).then(() => {
- return bridge._onEvent({
+ return bridge.onEvent({
type: "m.room.tombstone",
state_key: "",
sender: "@foo:bar",
});
}).then(() => {
- expect(bridge._roomUpgradeHandler.onTombstone).toHaveBeenCalled();
+ expect(bridge.roomUpgradeHandler.onTombstone).toHaveBeenCalled();
});
});
});
diff --git a/spec/integ/config-validator.spec.js b/spec/integ/config-validator.spec.js
index d32f8fd6..b4b84f8a 100644
--- a/spec/integ/config-validator.spec.js
+++ b/spec/integ/config-validator.spec.js
@@ -1,5 +1,5 @@
"use strict";
-var ConfigValidator = require("../..").ConfigValidator;
+const { ConfigValidator } = require("../..");
var log = require("../log");
describe("ConfigValidator", function() {
diff --git a/spec/unit/request.spec.js b/spec/unit/request.spec.js
index fc8ccea0..8a004fa9 100644
--- a/spec/unit/request.spec.js
+++ b/spec/unit/request.spec.js
@@ -35,7 +35,7 @@ describe("Request", function() {
it("resolve should resolve the promise in getPromise", function(done) {
req = new Request();
- req.getPromise().done(function(thing) {
+ req.getPromise().then(function(thing) {
expect(thing).toEqual("flibble");
done();
})
diff --git a/spec/unit/room-link-validator.spec.js b/spec/unit/room-link-validator.spec.js
index 0145cfd0..50bab1cb 100644
--- a/spec/unit/room-link-validator.spec.js
+++ b/spec/unit/room-link-validator.spec.js
@@ -1,6 +1,6 @@
const RVL = require("../../lib/components/room-link-validator")
const RoomLinkValidator = RVL.RoomLinkValidator;
-const Statuses = RVL.validationStatuses;
+const Statuses = RVL.RoomLinkValidatorStatus;
const AsBotMock = {
cachedCalled: false,
diff --git a/spec/unit/room-upgrade-handler.spec.js b/spec/unit/room-upgrade-handler.spec.js
index f8f7123e..69848dfc 100644
--- a/spec/unit/room-upgrade-handler.spec.js
+++ b/spec/unit/room-upgrade-handler.spec.js
@@ -1,4 +1,4 @@
-const RoomUpgradeHandler = require("../../lib/components/room-upgrade-handler")
+const { RoomUpgradeHandler } = require("../../lib/components/room-upgrade-handler")
describe("RoomUpgradeHandler", () => {
describe("constructor", () => {
diff --git a/src/bridge.js b/src/bridge.js
deleted file mode 100644
index 87b5b88f..00000000
--- a/src/bridge.js
+++ /dev/null
@@ -1,1267 +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 Datastore = require("nedb");
-const Bluebird = require("bluebird");
-const fs = require("fs");
-const util = require("util");
-const yaml = require("js-yaml");
-
-const AppServiceRegistration = require("matrix-appservice").AppServiceRegistration;
-const AppService = require("matrix-appservice").AppService;
-const MatrixScheduler = require("matrix-js-sdk").MatrixScheduler;
-
-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").Intent;
-const RoomBridgeStore = require("./components/room-bridge-store");
-const UserBridgeStore = require("./components/user-bridge-store");
-const EventBridgeStore = require("./components/event-bridge-store");
-const MatrixUser = require("./models/users/matrix").MatrixUser;
-const MatrixRoom = require("./models/rooms/matrix").MatrixRoom;
-const { PrometheusMetrics } = require("./components/prometheusmetrics");
-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 { InternalError, EventNotHandledError, wrapError } = require("./errors").unstable;
-const EventQueue = require("./components/event-queue").EventQueue;
-const deferPromise = require("./utils/promiseutil").defer;
-
-const log = require("./components/logging").get("bridge");
-
-// The frequency at which we will check the list of accumulated Intent objects.
-const INTENT_CULL_CHECK_PERIOD_MS = 1000 * 60; // once per minute
-// How long a given Intent object can hang around unused for.
-const INTENT_CULL_EVICT_AFTER_MS = 1000 * 60 * 15; // 15 minutes
-
-/**
- * @constructor
- * @param {Object} opts Options to pass to the bridge
- * @param {AppServiceRegistration|string} opts.registration Application service
- * registration object or path to the registration file.
- * @param {string} opts.homeserverUrl The base HS url
- * @param {string} opts.domain The domain part for user_ids and room aliases
- * e.g. "bar" in "@foo:bar".
- * @param {string} opts.networkName A human readable string that will be used when
- * the bridge signals errors to the client. Will not include in error events if ommited.
- * @param {Object} opts.controller The controller logic for the bridge.
- * @param {Bridge~onEvent} opts.controller.onEvent Function. Called when
- * an event has been received from the HS.
- * @param {Bridge~onUserQuery=} opts.controller.onUserQuery Function. If supplied,
- * the bridge will invoke this function when queried via onUserQuery. If
- * not supplied, no users will be provisioned on user queries. Provisioned users
- * will automatically be stored in the associated userStore
.
- * @param {Bridge~onAliasQuery=} opts.controller.onAliasQuery Function. If supplied,
- * the bridge will invoke this function when queried via onAliasQuery. If
- * not supplied, no rooms will be provisioned on alias queries. Provisioned rooms
- * will automatically be stored in the associated roomStore
.
- * @param {Bridge~onAliasQueried=} opts.controller.onAliasQueried Function.
- * If supplied, the bridge will invoke this function when a room has been created
- * via onAliasQuery.
- * @param {Bridge~onLog=} opts.controller.onLog Function. Invoked when
- * logging. Defaults to a function which logs to the console.
- * @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.
- * @param {(UserBridgeStore|string)=} opts.userStore The user store instance to
- * use, or the path to the user .db file to load. A database will be created if
- * this is not specified.
- * @param {(EventBridgeStore|string)=} opts.eventStore The event store instance to
- * use, or the path to the event .db file to load. This will NOT be created if it
- * isn't specified.
- * @param {MembershipCache=} opts.membershipCache The membership cache instance
- * to use, which can be manually created by a bridge for greater control over
- * caching. By default a membership cache will be created internally.
- * @param {boolean=} opts.suppressEcho True to stop receiving onEvent callbacks
- * for events which were sent by a bridge user. Default: true.
- * @param {ClientFactory=} opts.clientFactory The client factory instance to
- * use. If not supplied, one will be created.
- * @param {boolean} opts.logRequestOutcome True to enable SUCCESS/FAILED log lines
- * to be sent to onLog. Default: true.
- * @param {Object=} opts.intentOptions Options to supply to created Intent instances.
- * @param {Object=} opts.intentOptions.bot Options to supply to the bot intent.
- * @param {Object=} opts.intentOptions.clients Options to supply to the client intents.
- * @param {Object=} opts.escapeUserIds Escape userIds for non-bot intents with
- * {@link MatrixUser~escapeUserId}
- * Default: true
- * @param {Object=} opts.queue Options for the onEvent queue. When the bridge
- * receives an incoming transaction, it needs to asyncly query the data store for
- * contextual info before calling onEvent. A queue is used to keep the onEvent
- * calls consistent with the arrival order from the incoming transactions.
- * @param {string=} opts.queue.type The type of queue to use when feeding through
- * to {@link Bridge~onEvent}. One of: "none", single", "per_room". If "none",
- * events are fed through as soon as contextual info is obtained, which may result
- * in out of order events but stops HOL blocking. If "single", onEvent calls will
- * be in order but may be slower due to HOL blocking. If "per_room", a queue per
- * room ID is made which reduces the impact of HOL blocking to be scoped to a room.
- * Default: "single".
- * @param {boolean=} opts.queue.perRequest True to only feed through the next
- * event after the request object in the previous call succeeds or fails. It is
- * vital that you consistently resolve/reject the request if this is 'true',
- * else you will not get any further events from this queue. To aid debugging this,
- * consider setting a delayed listener on the request factory. If false, the mere
- * invockation of onEvent is enough to trigger the next event in the queue.
- * You probably want to set this to 'true' if your {@link Bridge~onEvent} is
- * performing async operations where ordering matters (e.g. messages). Default: false.
- * @param {boolean=} opts.disableContext True to disable {@link Bridge~BridgeContext}
- * parameters in {@link Bridge~onEvent}. Disabling the context makes the
- * bridge do fewer database lookups, but prevents there from being a
- * context
parameter. Default: false.
- * @param {boolean=} opts.disableStores True to disable enabling of stores.
- * This should be used by bridges that use their own database instances and
- * do not need any of the included store objects. This implies setting
- * disableContext to True. Default: false.
- * @param {Object=} opts.roomLinkValidation Options to supply to the room link
- * validator. If not defined then all room links are accepted.
- * @param {string} opts.roomLinkValidation.ruleFile A file containing rules
- * on which matrix rooms can be bridged.
- * @param {Object=} opts.roomLinkValidation.rules A object containing rules
- * on which matrix rooms can be bridged. This is used if ruleFile is undefined.
- * @param {boolean=} opts.roomLinkValidation.triggerEndpoint Enable the endpoint
- * to trigger a reload of the rules file.
- * Default: false
- * @param {string} opts.authenticateThirdpartyEndpoints Should the bridge authenticate
- * requests to third party endpoints. This is false by default to be backwards-compatible
- * with Synapse.
- * @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") {
- throw new Error("opts must be supplied.");
- }
- var required = [
- "homeserverUrl", "registration", "domain", "controller"
- ];
- required.forEach(function(key) {
- if (!opts[key]) {
- throw new Error("Missing '" + key + "' in opts.");
- }
- });
- if (typeof opts.controller.onEvent !== "function") {
- throw new Error("controller.onEvent is a required function");
- }
-
-
- if (opts.disableContext === undefined) {
- opts.disableContext = false;
- }
-
- if (opts.disableStores === true) {
- opts.disableStores = true;
- opts.disableContext = true;
- }
- else {
- opts.disableStores = false;
- }
-
- opts.authenticateThirdpartyEndpoints = opts.authenticateThirdpartyEndpoints || false;
-
- opts.userStore = opts.userStore || "user-store.db";
- opts.roomStore = opts.roomStore || "room-store.db";
-
- opts.eventStore = opts.eventStore || null; // Must be enabled
- opts.queue = opts.queue || {};
- opts.intentOptions = opts.intentOptions || {};
- opts.queue.type = opts.queue.type || "single";
- if (opts.queue.perRequest === undefined) {
- opts.queue.perRequest = false;
- }
- if (opts.logRequestOutcome === undefined) {
- opts.logRequestOutcome = true;
- }
-
- // Default: logger -> log to console
- opts.controller.onLog = opts.controller.onLog || function(text, isError) {
- if (isError) {
- log.error(text);
- return;
- }
- log.info(text);
- };
-
- // Default: suppress echo -> True
- if (opts.suppressEcho === undefined) {
- opts.suppressEcho = true;
- }
-
- // we'll init these at runtime
- this.appService = null;
- this.opts = opts;
- this._clientFactory = null;
- this._botClient = null;
- this._appServiceBot = null;
- this._requestFactory = null;
- this._botIntent = null;
- this._intents = {
- // user_id + request_id : Intent
- };
- this._intentLastAccessed = Object.create(null); // user_id + request_id : timestamp
- this._intentLastAccessedTimeout = null;
- this._powerLevelMap = {
- // room_id: event.content
- };
- this._membershipCache = opts.membershipCache || new MembershipCache();
- this._intentBackingStore = {
- setMembership: this._membershipCache.setMemberEntry.bind(this._membershipCache),
- setPowerLevelContent: this._setPowerLevelEntry.bind(this),
- getMembership: this._membershipCache.getMemberEntry.bind(this._membershipCache),
- getPowerLevelContent: this._getPowerLevelEntry.bind(this)
- };
- this._queue = EventQueue.create(this.opts.queue, this._onConsume.bind(this));
- this._prevRequestPromise = Bluebird.resolve();
- this._metrics = null; // an optional PrometheusMetrics instance
- this._roomLinkValidator = null;
- if (opts.roomUpgradeOpts) {
- opts.roomUpgradeOpts.consumeEvent = opts.roomUpgradeOpts.consumeEvent !== false ? true : false;
- if (this.opts.disableStores) {
- opts.roomUpgradeOpts.migrateStoreEntries = false;
- }
- this._roomUpgradeHandler = new RoomUpgradeHandler(opts.roomUpgradeOpts, this);
- }
- else {
- this._roomUpgradeHandler = null;
- }
-}
-
-/**
- * Load the user and room databases. Access them via getUserStore() and getRoomStore().
- * @return {Bluebird} Resolved/rejected when the user/room databases have been loaded.
- */
-Bridge.prototype.loadDatabases = function() {
- if (this.opts.disableStores) {
- return Bluebird.resolve();
- }
- // Load up the databases if they provided file paths to them (or defaults)
- if (typeof this.opts.userStore === "string") {
- this.opts.userStore = loadDatabase(this.opts.userStore, UserBridgeStore);
- }
- if (typeof this.opts.roomStore === "string") {
- this.opts.roomStore = loadDatabase(this.opts.roomStore, RoomBridgeStore);
- }
- if (typeof this.opts.eventStore === "string") {
- this.opts.eventStore = loadDatabase(this.opts.eventStore, EventBridgeStore);
- }
-
- // This works because if they provided a string we converted it to a Promise
- // which will be resolved when we have the db instance. If they provided a
- // db instance then this will resolve immediately.
- return Bluebird.all([
- Bluebird.resolve(this.opts.userStore).then((db) => {
- this._userStore = db;
- }),
- Bluebird.resolve(this.opts.roomStore).then((db) => {
- this._roomStore = db;
- }),
- Bluebird.resolve(this.opts.eventStore).then((db) => {
- this._eventStore = db;
- })
- ]);
-};
-
-/**
- * Run the bridge (start listening)
- * @param {Number} port The port to listen on.
- * @param {Object} config Configuration options
- * @param {AppService=} appServiceInstance The AppService instance to attach to.
- * If not provided, one will be created.
- * @param {String} hostname Optional hostname to bind to. (e.g. 0.0.0.0)
- * @return {Bluebird} A promise resolving when the bridge is ready
- */
-Bridge.prototype.run = function(port, config, appServiceInstance, hostname) {
- var self = this;
-
- // Load the registration file into an AppServiceRegistration object.
- if (typeof self.opts.registration === "string") {
- var regObj = yaml.safeLoad(fs.readFileSync(self.opts.registration, 'utf8'));
- self.opts.registration = AppServiceRegistration.fromObject(regObj);
- if (self.opts.registration === null) {
- throw new Error("Failed to parse registration file");
- }
- }
-
- this._clientFactory = self.opts.clientFactory || new ClientFactory({
- url: self.opts.homeserverUrl,
- token: self.opts.registration.getAppServiceToken(),
- appServiceUserId: `@${self.opts.registration.getSenderLocalpart()}:${self.opts.domain}`,
- clientSchedulerBuilder: function() {
- return new MatrixScheduler(retryAlgorithm, queueAlgorithm);
- },
- });
- this._clientFactory.setLogFunction(function(text, isErr) {
- if (!self.opts.controller.onLog) {
- return;
- }
- self.opts.controller.onLog(text, isErr);
- });
- this._botClient = this._clientFactory.getClientAs();
- this._appServiceBot = new AppServiceBot(
- this._botClient, self.opts.registration, this._membershipCache
- );
-
- if (this.opts.roomLinkValidation !== undefined) {
- this._roomLinkValidator = new RoomLinkValidator(
- this.opts.roomLinkValidation,
- this._appServiceBot
- );
- }
-
- this._requestFactory = new RequestFactory();
- if (this.opts.controller.onLog && this.opts.logRequestOutcome) {
- this._requestFactory.addDefaultResolveCallback(function(req, res) {
- self.opts.controller.onLog(
- "[" + req.getId() + "] SUCCESS (" + req.getDuration() + "ms)"
- );
- });
- this._requestFactory.addDefaultRejectCallback(function(req, err) {
- self.opts.controller.onLog(
- "[" + req.getId() + "] FAILED (" + req.getDuration() + "ms) " +
- (err ? util.inspect(err) : "")
- );
- });
- }
- var botIntentOpts = {
- registered: true,
- backingStore: this._intentBackingStore,
- };
- if (this.opts.intentOptions.bot) { // copy across opts
- Object.keys(this.opts.intentOptions.bot).forEach(function(k) {
- botIntentOpts[k] = self.opts.intentOptions.bot[k];
- });
- }
- this._botIntent = new Intent(this._botClient, this._botClient, botIntentOpts);
- this._intents = {
- // user_id + request_id : Intent
- };
-
- this.appService = appServiceInstance || new AppService({
- homeserverToken: this.opts.registration.getHomeserverToken()
- });
- this.appService.onUserQuery = (userId) => Bluebird.cast(this._onUserQuery(userId));
- this.appService.onAliasQuery = this._onAliasQuery.bind(this);
- this.appService.on("event", this._onEvent.bind(this));
- this.appService.on("http-log", function(line) {
- if (!self.opts.controller.onLog) {
- return;
- }
- self.opts.controller.onLog(line, false);
- });
- this._customiseAppservice();
- this._setupIntentCulling();
-
- if (this._metrics) {
- this._metrics.addAppServicePath(this);
- }
-
- // We MUST return a Bluebird-Promise instead of a Promise.
- // promise.done() is used by many tests in this repo.
- return this.loadDatabases().then(async() => {
- await this.appService.listen(port, hostname);
- });
-};
-
-/**
- * Apply any customisations required on the appService object.
- */
-Bridge.prototype._customiseAppservice = function() {
- if (this.opts.controller.thirdPartyLookup) {
- this._customiseAppserviceThirdPartyLookup(this.opts.controller.thirdPartyLookup);
- }
- if (this.opts.roomLinkValidation && this.opts.roomLinkValidation.triggerEndpoint) {
- this.addAppServicePath({
- method: "POST",
- path: "/_bridge/roomLinkValidator/reload",
- handler: (req, res) => {
- try {
- // Will use filename if provided, or the config
- // one otherwised.
- this._roomLinkValidator.readRuleFile(req.query.filename);
- res.status(200).send("Success");
- }
- catch (e) {
- res.status(500).send("Failed: " + e);
- }
- },
- });
- }
-};
-
-// Set a timer going which will periodically remove Intent objects to prevent
-// them from accumulating too much. Removal is based on access time (calls to
-// getIntent). Intents expire after INTENT_CULL_EVICT_AFTER_MS of not being called.
-Bridge.prototype._setupIntentCulling = function() {
- if (this._intentLastAccessedTimeout) {
- clearTimeout(this._intentLastAccessedTimeout);
- }
- var self = this;
- this._intentLastAccessedTimeout = setTimeout(function() {
- var now = Date.now();
- Object.keys(self._intentLastAccessed).forEach(function(key) {
- if ((self._intentLastAccessed[key] + INTENT_CULL_EVICT_AFTER_MS) < now) {
- delete self._intentLastAccessed[key];
- delete self._intents[key];
- }
- });
- self._intentLastAccessedTimeout = null;
- // repeat forever. We have no cancellation mechanism but we don't expect
- // Bridge objects to be continually recycled so this is fine.
- self._setupIntentCulling();
- }, INTENT_CULL_CHECK_PERIOD_MS);
-}
-
-Bridge.prototype._customiseAppserviceThirdPartyLookup = function(lookupController) {
- var protocols = lookupController.protocols || [];
-
- var _respondErr = function(e, res) {
- if (typeof e === "object" && e.code && e.err) {
- res.status(e.code).json({error: e.err});
- }
- else {
- res.status(500).send("Failed: " + e);
- }
- }
-
- if (lookupController.getProtocol) {
- var getProtocolFunc = lookupController.getProtocol;
-
- this.addAppServicePath({
- method: "GET",
- path: "/_matrix/app/:version(v1|unstable)/thirdparty/protocol/:protocol",
- checkToken: this.opts.authenticateThirdpartyEndpoints,
- handler: function(req, res) {
- const protocol = req.params.protocol;
-
- if (protocols.length && protocols.indexOf(protocol) === -1) {
- res.status(404).json({err: "Unknown 3PN protocol " + protocol});
- return;
- }
-
- getProtocolFunc(protocol).then(
- function(result) { res.status(200).json(result) },
- function(e) { _respondErr(e, res) }
- );
- },
- });
- }
-
- if (lookupController.getLocation) {
- var getLocationFunc = lookupController.getLocation;
-
- this.addAppServicePath({
- method: "GET",
- path: "/_matrix/app/:version(v1|unstable)/thirdparty/location/:protocol",
- checkToken: this.opts.authenticateThirdpartyEndpoints,
- handler: function(req, res) {
- const protocol = req.params.protocol;
-
- if (protocols.length && protocols.indexOf(protocol) === -1) {
- res.status(404).json({err: "Unknown 3PN protocol " + protocol});
- return;
- }
-
- // Do not leak access token to function
- delete req.query.access_token;
-
- getLocationFunc(protocol, req.query).then(
- function(result) { res.status(200).json(result) },
- function(e) { _respondErr(e, res) }
- );
- },
- });
- }
-
- if (lookupController.parseLocation) {
- var parseLocationFunc = lookupController.parseLocation;
-
- this.addAppServicePath({
- method: "GET",
- path: "/_matrix/app/:version(v1|unstable)/thirdparty/location",
- checkToken: this.opts.authenticateThirdpartyEndpoints,
- handler: function(req, res) {
- const alias = req.query.alias;
- if (!alias) {
- res.status(400).send({err: "Missing 'alias' parameter"});
- return;
- }
-
- parseLocationFunc(alias).then(
- function(result) { res.status(200).json(result) },
- function(e) { _respondErr(e, res) }
- );
- },
- });
- }
-
- if (lookupController.getUser) {
- var getUserFunc = lookupController.getUser;
-
- this.addAppServicePath({
- method: "GET",
- path: "/_matrix/app/:version(v1|unstable)/thirdparty/user/:protocol",
- checkToken: this.opts.authenticateThirdpartyEndpoints,
- handler: function(req, res) {
- const protocol = req.params.protocol;
-
- if (protocols.length && protocols.indexOf(protocol) === -1) {
- res.status(404).json({err: "Unknown 3PN protocol " + protocol});
- return;
- }
-
- // Do not leak access token to function
- delete req.query.access_token;
-
- getUserFunc(protocol, req.query).then(
- function(result) { res.status(200).json(result) },
- function(e) { _respondErr(e, res) }
- );
- }
- });
- }
-
- if (lookupController.parseUser) {
- var parseUserFunc = lookupController.parseUser;
-
- this.addAppServicePath({
- method: "GET",
- path: "/_matrix/app/:version(v1|unstable)/thirdparty/user",
- checkToken: this.opts.authenticateThirdpartyEndpoints,
- handler: function(req, res) {
- const userid = req.query.userid;
- if (!userid) {
- res.status(400).send({err: "Missing 'userid' parameter"});
- return;
- }
-
- parseUserFunc(userid).then(
- function(result) { res.status(200).json(result) },
- function(e) { _respondErr(e, res) }
- );
- },
- });
- }
-};
-
-/**
- * Install a custom handler for an incoming HTTP API request. This allows
- * callers to add extra functionality, implement new APIs, etc...
- * @param {Object} opts Named options
- * @param {string} opts.method The HTTP method name.
- * @param {string} opts.path Path to the endpoint.
- * @param {string} opts.checkToken Should the token be automatically checked. Defaults to true.
- * @param {Bridge~appServicePathHandler} opts.handler Function to handle requests
- * to this endpoint.
- */
-Bridge.prototype.addAppServicePath = function(opts) {
- // TODO(paul): This is gut-wrenching into the AppService instance itself.
- // Maybe an API on that object would be good?
- const app = this.appService.app;
- opts.checkToken = opts.checkToken !== undefined ? opts.checkToken : true;
-
- // TODO(paul): Consider more options:
- // opts.versions - automatic version filtering and rejecting of
- // unrecognised API versions
- // Consider automatic "/_matrix/app/:version(v1|unstable)" path prefix
- app[opts.method.toLowerCase()](opts.path, (req, res, ...args) => {
- if (opts.checkToken && !this.requestCheckToken(req)) {
- return res.status(403).send({
- errcode: "M_FORBIDDEN",
- error: "Bad token supplied,"
- });
- }
- return opts.handler(req, res, ...args);
- });
-};
-
-/**
- * Retrieve the connected room store instance.
- * @return {?RoomBridgeStore} The connected instance ready for querying.
- */
-Bridge.prototype.getRoomStore = function() {
- return this._roomStore;
-};
-
-/**
- * Retrieve the connected user store instance.
- * @return {?UserBridgeStore} The connected instance ready for querying.
- */
-Bridge.prototype.getUserStore = function() {
- return this._userStore;
-};
-
-/**
- * Retrieve the connected event store instance, if one was configured.
- * @return {?EventBridgeStore} The connected instance ready for querying.
- */
-Bridge.prototype.getEventStore = function() {
- return this._eventStore;
-};
-
-/**
- * Retrieve the request factory used to create incoming requests.
- * @return {RequestFactory}
- */
-Bridge.prototype.getRequestFactory = function() {
- return this._requestFactory;
-};
-
-/**
- * Retrieve the matrix client factory used when sending matrix requests.
- * @return {ClientFactory}
- */
-Bridge.prototype.getClientFactory = function() {
- return this._clientFactory;
-};
-
-/**
- * Get the AS bot instance.
- * @return {AppServiceBot}
- */
-Bridge.prototype.getBot = function() {
- return this._appServiceBot;
-};
-
-/**
- * Determines whether a room should be provisoned based on
- * user provided rules and the room state. Will default to true
- * if no rules have been provided.
- * @param {string} roomId The room to check.
- * @param {boolean} cache Should the validator check it's cache.
- * @returns {Promise} resolves if can and rejects if it cannot.
- * A status code is returned on both.
- */
-Bridge.prototype.canProvisionRoom = function(roomId, cache=true) {
- if (this._roomLinkValidator === null) {
- return Bluebird.resolve(RLVStatus.PASSED);
- }
- return this._roomLinkValidator.validateRoom(roomId, cache);
-}
-
-Bridge.prototype.getRoomLinkValidator = function() {
- return this._roomLinkValidator;
-}
-
-/**
- * Retrieve an Intent instance for the specified user ID. If no ID is given, an
- * instance for the bot itself is returned.
- * @param {?string} userId The user ID to get an Intent for.
- * @param {Request=} request Optional. The request instance to tie the MatrixClient
- * instance to. Useful for logging contextual request IDs.
- * @return {Intent} The intent instance
- */
-Bridge.prototype.getIntent = function(userId, request) {
- if (!userId) {
- return this._botIntent;
- }
- if (this.opts.escapeUserIds === undefined || this.opts.escapeUserIds) {
- userId = new MatrixUser(userId).getId(); // Escape the ID
- }
- const key = userId + (request ? request.getId() : "");
- if (!this._intents[key]) {
- const client = this._clientFactory.getClientAs(userId, request);
- const clientIntentOpts = {
- backingStore: this._intentBackingStore
- };
- if (this.opts.intentOptions.clients) {
- Object.keys(this.opts.intentOptions.clients).forEach((k) => {
- clientIntentOpts[k] = this.opts.intentOptions.clients[k];
- });
- }
- clientIntentOpts.registered = this._membershipCache.isUserRegistered(userId);
- this._intents[key] = new Intent(client, this._botClient, clientIntentOpts);
- }
- this._intentLastAccessed[key] = Date.now();
- return this._intents[key];
-};
-
-/**
- * Retrieve an Intent instance for the specified user ID localpart. This must
- * be the complete user localpart.
- * @param {?string} localpart The user ID localpart to get an Intent for.
- * @param {Request=} request Optional. The request instance to tie the MatrixClient
- * instance to. Useful for logging contextual request IDs.
- * @return {Intent} The intent instance
- */
-Bridge.prototype.getIntentFromLocalpart = function(localpart, request) {
- return this.getIntent(
- "@" + localpart + ":" + this.opts.domain
- );
-};
-
-/**
- * Provision a user on the homeserver.
- * @param {MatrixUser} matrixUser The virtual user to be provisioned.
- * @param {Bridge~ProvisionedUser} provisionedUser Provisioning information.
- * @return {Promise} Resolved when provisioned.
- */
-Bridge.prototype.provisionUser = function (matrixUser, provisionedUser) {
- // For backwards compat
- return Bluebird.cast(this._provisionUser(matrixUser, provisionedUser));
-};
-
-Bridge.prototype._provisionUser = async function(matrixUser, provisionedUser) {
- await this._botClient.register(matrixUser.localpart);
-
- if (!this.opts.disableStores) {
- await this._userStore.setMatrixUser(matrixUser);
- if (provisionedUser.remote) {
- await this._userStore.linkUsers(matrixUser, provisionedUser.remote);
- }
- }
- const userClient = this._clientFactory.getClientAs(matrixUser.getId());
- if (provisionedUser.name) {
- await userClient.setDisplayName(provisionedUser.name);
- }
- if (provisionedUser.url) {
- await userClient.setAvatarUrl(provisionedUser.url);
- }
-};
-
-Bridge.prototype._onUserQuery = async function(userId) {
- if (!this.opts.controller.onUserQuery) {
- return;
- }
- const matrixUser = new MatrixUser(userId);
- try {
- const provisionedUser = await this.opts.controller.onUserQuery(matrixUser);
- if (!provisionedUser) {
- log.warn(`Not provisioning user for ${userId}`);
- return;
- }
- await this.provisionUser(matrixUser, provisionedUser);
- }
- catch (ex) {
- log.error(`Failed _onUserQuery for ${userId}`, ex);
- }
-};
-
-Bridge.prototype._onAliasQuery = function (alias) {
- // For backwards compat
- return Bluebird.cast(this.__onAliasQuery(alias));
-};
-
-Bridge.prototype.__onAliasQuery = async function(alias) {
- if (!this.opts.controller.onAliasQuery) {
- return;
- }
- const aliasLocalpart = alias.split(":")[0].substring(1);
- const provisionedRoom = await this.opts.controller.onAliasQuery(alias, aliasLocalpart);
- if (!provisionedRoom) {
- throw new Error("Not provisioning room for this alias");
- }
- const createRoomResponse = await this._botClient.createRoom(
- provisionedRoom.creationOpts
- );
- const roomId = createRoomResponse.room_id;
- if (!this.opts.disableStores) {
- const matrixRoom = new MatrixRoom(roomId);
- const remoteRoom = provisionedRoom.remote;
- if (remoteRoom) {
- await this._roomStore.linkRooms(matrixRoom, remoteRoom);
- }
- else {
- // store the matrix room only
- await this._roomStore.setMatrixRoom(matrixRoom);
- }
- }
- if (this.opts.controller.onAliasQueried) {
- await this.opts.controller.onAliasQueried(alias, roomId);
- }
-}
-
-Bridge.prototype._onEvent = function (event) {
- return Bluebird.cast(this.__onEvent(event));
-};
-
-// returns a Promise for the request linked to this event for testing.
-Bridge.prototype.__onEvent = async function(event) {
- const isCanonicalState = event.state_key === "";
- this._updateIntents(event);
- if (this.opts.suppressEcho &&
- this.opts.registration.isUserMatch(event.sender, true)) {
- return null;
- }
-
- if (this._roomUpgradeHandler) {
- // m.room.tombstone is the event that signals a room upgrade.
- if (event.type === "m.room.tombstone" && isCanonicalState && this._roomUpgradeHandler) {
- this._roomUpgradeHandler.onTombstone(event);
- if (this.opts.roomUpgradeOpts.consumeEvent) {
- return null;
- }
- }
- 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 = await this._roomUpgradeHandler.onInvite(event);
- if (isUpgradeInvite &&
- this.opts.roomUpgradeOpts.consumeEvent) {
- return null;
- }
- }
- }
-
- const request = this._requestFactory.newRequest({ data: event });
- const contextReady = this._getBridgeContext(event);
- const dataReady = contextReady.then(context => ({ request, context }));
-
- const dataReadyLimited = this._limited(dataReady, request);
-
- this._queue.push(event, dataReadyLimited);
- this._queue.consume();
- const reqPromise = request.getPromise();
-
- // We *must* return the result of the request.
- return reqPromise.catch(
- EventNotHandledError,
- e => {
- this._handleEventError(event, e)
- }
- );
-};
-
-/**
- * Restricts the promise according to the bridges `perRequest` setting.
- *
- * `perRequest` enabled:
- * Returns a promise similar to `promise`, with the addition of it only
- * resolving after `request`.
- * `perRequest` disabled:
- * Returns the promise unchanged.
- */
-Bridge.prototype._limited = async function(promise, request) {
- // queue.perRequest controls whether multiple request can be processed by
- // the bridge at once.
- if (this.opts.queue.perRequest) {
- const promiseLimited = this._prevRequestPromise.reflect().return(promise);
- this._prevRequestPromise = request.getPromise();
- return promiseLimited;
- }
-
- return promise;
-}
-
-Bridge.prototype._onConsume = function(err, data) {
- if (err) {
- // The data for the event could not be retrieved.
- this.opts.controller.onLog("onEvent failure: " + err, true);
- return;
- }
-
- this.opts.controller.onEvent(data.request, data.context);
-};
-
-Bridge.prototype._getBridgeContext = async function(event) {
- if (this.opts.disableContext) {
- return null;
- }
-
- const context = new BridgeContext({
- sender: event.sender,
- target: event.type === "m.room.member" ? event.state_key : null,
- room: event.room_id
- });
-
- return context.get(this._roomStore, this._userStore);
-}
-
-Bridge.prototype._handleEventError = function(event, error) {
- if (!(error instanceof EventNotHandledError)) {
- error = wrapError(error, InternalError);
- }
- // TODO[V02460@gmail.com]: Send via different means when the bridge bot is
- // unavailable. _MSC2162: Signaling Errors at Bridges_ will have details on
- // how this should be done.
- this._botIntent.unstableSignalBridgeError(
- event.room_id,
- event.event_id,
- this.opts.networkName,
- error.reason,
- this._getUserRegex(),
- );
-};
-
-/**
- * Returns a regex matching all users of the bridge.
- *
- * @return {string} Super regex composed of all user regexes.
- */
-Bridge.prototype._getUserRegex = function() {
- const reg = this.opts.registration;
- return reg.namespaces["users"].map(o => o.regex);
-};
-
-Bridge.prototype._updateIntents = function(event) {
- if (event.type === "m.room.member") {
- this._membershipCache.setMemberEntry(
- event.room_id,
- event.state_key,
- event.content ? event.content.membership : null
- );
- }
- else if (event.type === "m.room.power_levels") {
- this._setPowerLevelEntry(event.room_id, event.content);
- }
-};
-
-Bridge.prototype._setPowerLevelEntry = function(roomId, content) {
- this._powerLevelMap[roomId] = content;
-};
-
-Bridge.prototype._getPowerLevelEntry = function(roomId) {
- return this._powerLevelMap[roomId];
-};
-
-/**
- * Returns a PrometheusMetrics instance stored on the bridge, creating it first
- * if required. The instance will be registered with the HTTP server so it can
- * serve the "/metrics" page in the usual way.
- * The instance will automatically register the Matrix SDK metrics by calling
- * {PrometheusMetrics~registerMatrixSdkMetrics}.
- * @param {boolean} registerEndpoint Register the /metrics endpoint on the appservice HTTP server. Defaults to true.
- * @param {Registry?} registry Optionally provide an alternative registry for metrics.
- */
-Bridge.prototype.getPrometheusMetrics = function(registerEndpoint = true, registry = undefined) {
- if (this._metrics) {
- return this._metrics;
- }
-
- const metrics = this._metrics = new PrometheusMetrics(registry);
-
- metrics.registerMatrixSdkMetrics();
-
- // TODO(paul): register some bridge-wide standard ones here
-
- // In case we're called after .run()
- if (this.appService && registerEndpoint) {
- metrics.addAppServicePath(this);
- }
-
- return metrics;
-};
-
-/**
- * A convenient shortcut to calling registerBridgeGauges() on the
- * PrometheusMetrics instance directly. This version will supply the value of
- * the matrixGhosts field if the counter function did not return it, for
- * convenience.
- * @param {PrometheusMetrics~BridgeGaugesCallback} counterFunc A function that
- * when invoked returns the current counts of various items in the bridge.
- *
- * @example
- * bridge.registerBridgeGauges(() => {
- * return {
- * matrixRoomConfigs: Object.keys(this.matrixRooms).length,
- * remoteRoomConfigs: Object.keys(this.remoteRooms).length,
- *
- * remoteGhosts: Object.keys(this.remoteGhosts).length,
- *
- * ...
- * }
- * })
- */
-Bridge.prototype.registerBridgeGauges = function(counterFunc) {
- var self = this;
-
- this.getPrometheusMetrics().registerBridgeGauges(function() {
- var counts = counterFunc();
-
- if (!("matrixGhosts" in counts)) {
- counts.matrixGhosts = Object.keys(self._intents).length;
- }
-
- return counts;
- });
-};
-
-/**
- * Check a express Request to see if it's correctly
- * authenticated (includes the hsToken). The query parameter `access_token`
- * and the `Authorization` header are checked.
- * @returns {Boolean} True if authenticated, False if not.
- */
-Bridge.prototype.requestCheckToken = function(req) {
- if (
- req.query.access_token !== this.opts.registration.getHomeserverToken() &&
- req.get("authorization") !== `Bearer ${this.opts.registration.getHomeserverToken()}`
- ) {
- return false;
- }
- return true;
-}
-
-function loadDatabase(path, Cls) {
- const defer = deferPromise();
- var db = new Datastore({
- filename: path,
- autoload: true,
- onload: function(err) {
- if (err) {
- defer.reject(err);
- }
- else {
- defer.resolve(new Cls(db));
- }
- }
- });
- return defer.promise;
-}
-
-function retryAlgorithm(event, attempts, err) {
- if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) {
- // client error; no amount of retrying with save you now.
- return -1;
- }
- // we ship with browser-request which returns { cors: rejected } when trying
- // with no connection, so if we match that, give up since they have no conn.
- if (err.cors === "rejected") {
- return -1;
- }
-
- if (err.name === "M_LIMIT_EXCEEDED") {
- var waitTime = err.data.retry_after_ms;
- if (waitTime) {
- return waitTime;
- }
- }
- if (attempts > 4) {
- return -1; // give up
- }
- return 1000 + (1000 * attempts);
-}
-
-function queueAlgorithm(event) {
- if (event.getType() === "m.room.message") {
- // use a separate queue for each room ID
- return "message_" + event.getRoomId();
- }
- // allow all other events continue concurrently.
- return null;
-}
-
-
-module.exports = Bridge;
-
-
-/**
- * @typedef Bridge~ProvisionedUser
- * @type {Object}
- * @property {string=} name The display name to set for the provisioned user.
- * @property {string=} url The avatar URL to set for the provisioned user.
- * @property {RemoteUser=} remote The remote user to link to the provisioned user.
- */
-
-/**
- * @typedef Bridge~ProvisionedRoom
- * @type {Object}
- * @property {Object} creationOpts Room creation options to use when creating the
- * room. Required.
- * @property {RemoteRoom=} remote The remote room to link to the provisioned room.
- */
-
-/**
- * Invoked when the bridge receives a user query from the homeserver. Supports
- * both sync return values and async return values via promises.
- * @callback Bridge~onUserQuery
- * @param {MatrixUser} matrixUser The matrix user queried. Use getId()
- * to get the user ID.
- * @return {?Bridge~ProvisionedUser|Promise}
- * Reject the promise / return null to not provision the user. Resolve the
- * promise / return a {@link Bridge~ProvisionedUser} object to provision the user.
- * @example
- * new Bridge({
- * controller: {
- * onUserQuery: function(matrixUser) {
- * var remoteUser = new RemoteUser("some_remote_id");
- * return {
- * name: matrixUser.localpart + " (Bridged)",
- * url: "http://someurl.com/pic.jpg",
- * user: remoteUser
- * };
- * }
- * }
- * });
- */
-
-/**
- * Invoked when the bridge receives an alias query from the homeserver. Supports
- * both sync return values and async return values via promises.
- * @callback Bridge~onAliasQuery
- * @param {string} alias The alias queried.
- * @param {string} aliasLocalpart The parsed localpart of the alias.
- * @return {?Bridge~ProvisionedRoom|Promise}
- * Reject the promise / return null to not provision the room. Resolve the
- * promise / return a {@link Bridge~ProvisionedRoom} object to provision the room.
- * @example
- * new Bridge({
- * controller: {
- * onAliasQuery: function(alias, aliasLocalpart) {
- * return {
- * creationOpts: {
- * room_alias_name: aliasLocalpart, // IMPORTANT: must be set to make the link
- * name: aliasLocalpart,
- * topic: "Auto-generated bridged room"
- * }
- * };
- * }
- * }
- * });
- */
-
- /**
- * Invoked when a response is returned from onAliasQuery. Supports
- * both sync return values and async return values via promises.
- * @callback Bridge~onAliasQueried
- * @param {string} alias The alias queried.
- * @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
- * @param {Request} request The request to resolve or reject depending on the
- * outcome of this request. The 'data' attached to this Request is the raw event
- * JSON received (accessed via request.getData()
)
- * @param {Bridge~BridgeContext} context Context for this event, including
- * instantiated client instances.
- */
-
- /**
- * Invoked when the bridge is attempting to log something.
- * @callback Bridge~onLog
- * @param {string} line The text to be logged.
- * @param {boolean} isError True if this line should be treated as an error msg.
- */
-
- /**
- * Handler function for custom applied HTTP API request paths. This is invoked
- * as defined by expressjs.
- * @callback Bridge~appServicePathHandler
- * @param {Request} req An expressjs Request object the handler can use to
- * inspect the incoming request.
- * @param {Response} res An expressjs Response object the handler can use to
- * send the outgoing response.
- */
-
- /**
- * @typedef Bridge~thirdPartyLookup
- * @type {Object}
- * @property {string[]} protocols Optional list of recognised protocol names.
- * If present, lookups for unrecognised protocols will be automatically
- * rejected.
- * @property {Bridge~getProtocol} getProtocol Function. Called for requests
- * for 3PE query metadata.
- * @property {Bridge~getLocation} getLocation Function. Called for requests
- * for 3PLs.
- * @property {Bridge~parseLocation} parseLocation Function. Called for reverse
- * parse requests on 3PL aliases.
- * @property {Bridge~getUser} getUser Function. Called for requests for 3PUs.
- * @property {Bridge~parseUser} parseUser Function. Called for reverse parse
- * requests on 3PU user IDs.
- */
-
- /**
- * Invoked on requests for 3PE query metadata
- * @callback Bridge~getProtocol
- * @param {string} protocol The name of the 3PE protocol to query
- * @return {Promise} A Promise of metadata
- * about 3PE queries that can be made for this protocol.
- */
-
- /**
- * Returned by getProtocol third-party query metadata requests
- * @typedef Bridge~thirdPartyProtocolResult
- * @type {Object}
- * @property {string[]} [location_fields] Names of the fields required for
- * location lookups if location queries are supported.
- * @property {string[]} [user_fields] Names of the fields required for user
- * lookups if user queries are supported.
-
- /**
- * Invoked on requests for 3PLs
- * @callback Bridge~getLocation
- * @param {string} protocol The name of the 3PE protocol
- * @param {Object} fields The location query field data as specified by the
- * specific protocol.
- * @return {Promise} A Promise of a list of
- * 3PL lookup results.
- */
-
- /**
- * Invoked on requests to parse 3PL aliases
- * @callback Bridge~parseLocation
- * @param {string} alias The room alias to parse.
- * @return {Promise} A Promise of a list of
- * 3PL lookup results.
- */
-
- /**
- * Returned by getLocation and parseLocation third-party location lookups
- * @typedef Bridge~thirdPartyLocationResult
- * @type {Object}
- * @property {string} alias The Matrix room alias to the portal room
- * representing this 3PL
- * @property {string} protocol The name of the 3PE protocol
- * @property {object} fields The normalised values of the location query field
- * data.
- */
-
- /**
- * Invoked on requests for 3PUs
- * @callback Bridge~getUser
- * @param {string} protocol The name of the 3PE protocol
- * @param {Object} fields The user query field data as specified by the
- * specific protocol.
- * @return {Promise} A Promise of a list of 3PU
- * lookup results.
- */
-
- /**
- * Invoked on requests to parse 3PU user IDs
- * @callback Bridge~parseUser
- * @param {string} userid The user ID to parse.
- * @return {Promise} A Promise of a list of 3PU
- * lookup results.
- */
-
- /**
- * Returned by getUser and parseUser third-party user lookups
- * @typedef Bridge~thirdPartyUserResult
- * @type {Object}
- * @property {string} userid The Matrix user ID for the ghost representing
- * this 3PU
- * @property {string} protocol The name of the 3PE protocol
- * @property {object} fields The normalised values of the user query field
- * data.
- */
diff --git a/src/bridge.ts b/src/bridge.ts
new file mode 100644
index 00000000..2629888f
--- /dev/null
+++ b/src/bridge.ts
@@ -0,0 +1,1445 @@
+/*
+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 Datastore from "nedb";
+import Bluebird from "bluebird";
+import * as fs from "fs";
+import * as util from "util";
+import yaml from "js-yaml";
+import { Application, Request as ExRequest, Response as ExResponse, NextFunction } from "express";
+
+const MatrixScheduler = require("matrix-js-sdk").MatrixScheduler;
+
+import { AppServiceRegistration, AppService } from "matrix-appservice";
+import { BridgeContext } from "./components/bridge-context"
+import { ClientFactory } from "./components/client-factory"
+import { AppServiceBot } from "./components/app-service-bot"
+import { RequestFactory } from "./components/request-factory";
+import { Request } from "./components/request";
+import { Intent, IntentOpts, IntentBackingStore, PowerLevelContent, RoomCreationOpts } from "./components/intent";
+import { RoomBridgeStore } from "./components/room-bridge-store";
+import { UserBridgeStore } from "./components/user-bridge-store";
+import { EventBridgeStore } from "./components/event-bridge-store";
+import { MatrixUser } from "./models/users/matrix"
+import { MatrixRoom } from "./models/rooms/matrix"
+import { PrometheusMetrics, BridgeGaugesCounts } from "./components/prometheusmetrics"
+import { MembershipCache, UserMembership } from "./components/membership-cache"
+import { RoomLinkValidator, RoomLinkValidatorStatus, Rules } from "./components/room-link-validator"
+import { RoomUpgradeHandler, RoomUpgradeHandlerOpts } from "./components/room-upgrade-handler";
+import { EventQueue } from "./components/event-queue";
+import * as logging from "./components/logging";
+import { defer as deferPromise } from "./utils/promiseutil";
+import { unstable } from "./errors";
+import { BridgeStore } from "./components/bridge-store";
+import { RemoteUser } from "./models/users/remote";
+import BridgeInternalError = unstable.BridgeInternalError;
+import wrapError = unstable.wrapError;
+import EventNotHandledError = unstable.EventNotHandledError;
+import EventUnknownError = unstable.EventUnknownError;
+import e = require("express");
+import { ThirdpartyProtocolResponse, ThirdpartyLocationResponse, ThirdpartyUserResponse } from "./thirdparty";
+import { RemoteRoom } from "./models/rooms/remote";
+
+const log = logging.get("bridge");
+
+// The frequency at which we will check the list of accumulated Intent objects.
+const INTENT_CULL_CHECK_PERIOD_MS = 1000 * 60; // once per minute
+// How long a given Intent object can hang around unused for.
+const INTENT_CULL_EVICT_AFTER_MS = 1000 * 60 * 15; // 15 minutes
+
+export interface WeakEvent extends Record {
+ event_id: string;
+ room_id: string;
+ sender: string;
+ content: unknown;
+ unsigned: {
+ age: number;
+ }
+ origin_server_ts: number;
+ state_key: string;
+ type: string;
+}
+
+interface BridgeOpts {
+ /**
+ * Application service registration object or path to the registration file.
+ */
+ registration: AppServiceRegistration|string;
+ /**
+ * The base HS url
+ */
+ homeserverUrl: string;
+ /**
+ * The domain part for user_ids and room aliases e.g. "bar" in "@foo:bar".
+ */
+ domain: string;
+ /**
+ * A human readable string that will be used when the bridge signals errors
+ * to the client. Will not include in error events if ommited.
+ */
+ networkName?: string;
+ /**
+ * The controller logic for the bridge.
+ */
+ controller: {
+ /**
+ * The bridge will invoke when an event has been received from the HS.
+ */
+ onEvent: (request: Request, context?: BridgeContext) => void;
+ /**
+ * The bridge will invoke this function when queried via onUserQuery. If
+ * not supplied, no users will be provisioned on user queries. Provisioned users
+ * will automatically be stored in the associated `userStore`.
+ */
+ onUserQuery?: (matrixUser: MatrixUser) => {name?: string, url?: string, remote?: RemoteUser}|void;
+ /**
+ * The bridge will invoke this function when queried via onAliasQuery. If
+ * not supplied, no rooms will be provisioned on alias queries. Provisioned rooms
+ * will automatically be stored in the associated `roomStore`. */
+ onAliasQuery?: (alias: string, aliasLocalpart: string) => {creationOpts: Record, remote?: RemoteRoom};
+ /**
+ * The bridge will invoke this function when a room has been created
+ * via onAliasQuery.
+ */
+ onAliasQueried?: (alias: string, roomId: string) => void;
+ /**
+ * Invoked when logging. Defaults to a function which logs to the console.
+ * */
+ onLog?: (text: string, isError?: boolean) => void;
+ /**
+ * The bridge will invoke this function when it sees an upgrade event
+ * for a room. If not supplied, no action will be performed on room upgrade.
+ * */
+ onRoomUpgrade?: () => void;
+ /**
+ * If supplied, the bridge will respond to third-party entity lookups using the
+ * contained helper functions.
+ */
+ thirdPartyLookup?: {
+ protocols: string[];
+ getProtocol(protocol: string): ThirdpartyProtocolResponse|Promise;
+ getLocation(protocol: string, fields: Record):
+ ThirdpartyLocationResponse[]|Promise;
+ parseLocation(alias: string): ThirdpartyLocationResponse[]|Promise;
+ getUser(protocol: string, fields: Record):
+ ThirdpartyUserResponse[]|Promise;
+ parseUser(userid: string): ThirdpartyLocationResponse[]|Promise;
+ };
+ };
+ /**
+ * True to disable enabling of stores.
+ * This should be used by bridges that use their own database instances and
+ * do not need any of the included store objects. This implies setting
+ * disableContext to True. Default: false.
+ */
+ disableStores?: boolean;
+ /**
+ * 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. If `disableStores` is set,
+ * no database will be created or used.
+ */
+ roomStore?: RoomBridgeStore|string;
+ /**
+ * The user store instance to use, or the path to the user .db file to load.
+ * A database will be created if this is not specified. If `disableStores` is set,
+ * no database will be created or used.
+ */
+ userStore?: UserBridgeStore|string;
+ /**
+ * The event store instance to use, or the path to the user .db file to load.
+ * A database will NOT be created if this is not specified. If `disableStores` is set,
+ * no database will be created or used.
+ */
+ eventStore?: EventBridgeStore|string;
+ /**
+ * The membership cache instance
+ * to use, which can be manually created by a bridge for greater control over
+ * caching. By default a membership cache will be created internally.
+ */
+ membershipCache?: MembershipCache;
+ /**
+ * True to stop receiving onEvent callbacks
+ * for events which were sent by a bridge user. Default: true.
+ */
+ suppressEcho?: boolean;
+ /**
+ * The client factory instance to use. If not supplied, one will be created.
+ */
+ clientFactory?: ClientFactory;
+ /**
+ * True to enable SUCCESS/FAILED log lines to be sent to onLog. Default: true.
+ */
+ logRequestOutcome?: boolean;
+ /**
+ * Escape userIds for non-bot intents with
+ * {@link MatrixUser~escapeUserId}
+ * Default: true
+ */
+ escapeUserIds?: boolean;
+ /**
+ * Options to supply to created Intent instances.
+ */
+ intentOptions?: {
+ /**
+ * Options to supply to the bot intent.
+ */
+ bot?: IntentOpts;
+ /**
+ * Options to supply to the client intents.
+ */
+ clients?: IntentOpts;
+ };
+ /**
+ * Options for the `onEvent` queue. When the bridge
+ * receives an incoming transaction, it needs to asyncly query the data store for
+ * contextual info before calling onEvent. A queue is used to keep the onEvent
+ * calls consistent with the arrival order from the incoming transactions.
+ */
+ queue?: {
+ /**
+ * The type of queue to use when feeding through to {@link Bridge~onEvent}.
+ * - If `none`, events are fed through as soon as contextual info is obtained, which may result
+ * in out of order events but stops HOL blocking.
+ * - If `single`, onEvent calls will be in order but may be slower due to HOL blocking.
+ * - If `per_room`, a queue per room ID is made which reduces the impact of HOL blocking to be scoped to a room.
+ *
+ * Default: `single`.
+ */
+ type: "none"|"single"|"per_room";
+ /**
+ * `true` to only feed through the next event after the request object in the previous
+ * call succeeds or fails. It is **vital** that you consistently resolve/reject the
+ * request if this is 'true', else you will not get any further events from this queue.
+ * To aid debugging this, consider setting a delayed listener on the request factory.
+ *
+ * If `false`, the mere invockation of onEvent is enough to trigger the next event in the queue.
+ * You probably want to set this to `true` if your {@link Bridge~onEvent} is
+ * performing async operations where ordering matters (e.g. messages).
+ *
+ * Default: false.
+ * */
+ perRequest: boolean;
+ };
+ /**
+ * `true` to disable {@link BridgeContext}
+ * parameters in {@link Bridge.onEvent}. Disabling the context makes the
+ * bridge do fewer database lookups, but prevents there from being a
+ * `context` parameter.
+ *
+ * Default: `false`.
+ */
+ disableContext: boolean;
+ roomLinkValidation?: {
+ ruleFile?: string;
+ rules?: Rules;
+ triggerEndpoint?: boolean;
+ };
+ authenticateThirdpartyEndpoints?: boolean;
+ roomUpgradeOpts: RoomUpgradeHandlerOpts;
+}
+
+export class Bridge {
+ private requestFactory: RequestFactory;
+ private intents: Map; // user_id + request_id => Intent
+ private powerlevelMap: Map; // room_id => powerlevels
+ private membershipCache: MembershipCache;
+ private queue: EventQueue;
+ private intentBackingStore: IntentBackingStore;
+ private prevRequestPromise: Promise;
+ private readonly onLog: (message: string, isError?: boolean) => void;
+
+ private intentLastAccessedTimeout: NodeJS.Timeout|null = null;
+ private botIntent?: Intent;
+ private appServiceBot?: AppServiceBot;
+ private clientFactory?: ClientFactory;
+ private botClient?: any;
+ private metrics?: PrometheusMetrics;
+ private roomLinkValidator?: RoomLinkValidator;
+ private roomUpgradeHandler?: RoomUpgradeHandler;
+ private roomStore?: RoomBridgeStore;
+ private userStore?: UserBridgeStore;
+ private eventStore?: EventBridgeStore;
+ private registration?: AppServiceRegistration;
+ private appservice?: AppService;
+
+ public get appService() {
+ return this.appservice;
+ }
+
+ /**
+ * @param opts Options to pass to the bridge
+ * @param {RoomUpgradeHandler~Options} opts.roomUpgradeOpts Options to supply to
+ * the room upgrade handler. If not defined then upgrades are NOT handled by the bridge.
+ */
+ constructor (public readonly opts: BridgeOpts) {
+ if (typeof opts !== "object") {
+ throw new Error("opts must be supplied.");
+ }
+ const required = [
+ "homeserverUrl", "registration", "domain", "controller"
+ ];
+ const missingKeys = required.filter(k => !Object.keys(opts).includes(k));
+ if (missingKeys.length) {
+ throw new Error(`Missing '${missingKeys.join("', '")}' in opts.`);
+ }
+
+ if (typeof opts.controller.onEvent !== "function") {
+ throw new Error("controller.onEvent is a required function");
+ }
+
+
+ if (opts.disableContext === undefined) {
+ opts.disableContext = false;
+ }
+
+ if (opts.disableStores === true) {
+ opts.disableStores = true;
+ opts.disableContext = true;
+ }
+ else {
+ opts.disableStores = false;
+ }
+
+ opts.authenticateThirdpartyEndpoints = opts.authenticateThirdpartyEndpoints || false;
+
+ opts.userStore = opts.userStore || "user-store.db";
+ opts.roomStore = opts.roomStore || "room-store.db";
+
+ opts.intentOptions = opts.intentOptions || {};
+
+ opts.queue = opts.queue || {
+ type: "single",
+ perRequest: false,
+ };
+ opts.queue.type = opts.queue.type || "single";
+ if (opts.queue.perRequest === undefined) {
+ opts.queue.perRequest = false;
+ }
+ if (opts.logRequestOutcome === undefined) {
+ opts.logRequestOutcome = true;
+ }
+ this.queue = EventQueue.create(opts.queue, this.onConsume.bind(this));
+
+ // Default: logger -> log to console
+ this.onLog = opts.controller.onLog || function(text, isError) {
+ if (isError) {
+ log.error(text);
+ return;
+ }
+ log.info(text);
+ };
+
+ // Default: suppress echo -> True
+ if (opts.suppressEcho === undefined) {
+ opts.suppressEcho = true;
+ }
+
+ // we'll init these at runtime
+ this.opts = opts;
+ this.requestFactory = new RequestFactory();
+ this.intents = new Map();
+ this.powerlevelMap = new Map();
+ this.membershipCache = opts.membershipCache || new MembershipCache();
+ this.intentBackingStore = {
+ setMembership: this.membershipCache.setMemberEntry.bind(this.membershipCache),
+ setPowerLevelContent: this.setPowerLevelEntry.bind(this),
+ getMembership: this.membershipCache.getMemberEntry.bind(this.membershipCache),
+ getPowerLevelContent: this.getPowerLevelEntry.bind(this)
+ };
+
+ this.prevRequestPromise = Promise.resolve();
+
+ if (opts.roomUpgradeOpts) {
+ opts.roomUpgradeOpts.consumeEvent = opts.roomUpgradeOpts.consumeEvent !== false ? true : false;
+ if (this.opts.disableStores) {
+ opts.roomUpgradeOpts.migrateStoreEntries = false;
+ }
+ this.roomUpgradeHandler = new RoomUpgradeHandler(opts.roomUpgradeOpts, this);
+ }
+ }
+
+ /**
+ * Load the user and room databases. Access them via getUserStore() and getRoomStore().
+ */
+ public async loadDatabases() {
+ if (this.opts.disableStores) {
+ return;
+ }
+
+ const storePromises: Promise[] = [];
+ // Load up the databases if they provided file paths to them (or defaults)
+ if (typeof this.opts.userStore === "string") {
+ storePromises.push(loadDatabase(this.opts.userStore, UserBridgeStore));
+ }
+ else if (this.opts.userStore) {
+ storePromises.push(Promise.resolve(this.opts.userStore));
+ }
+ if (typeof this.opts.roomStore === "string") {
+ storePromises.push(loadDatabase(this.opts.roomStore, RoomBridgeStore));
+ }
+ else if (this.opts.roomStore) {
+ storePromises.push(Promise.resolve(this.opts.roomStore));
+ }
+ if (typeof this.opts.eventStore === "string") {
+ storePromises.push(loadDatabase(this.opts.eventStore, EventBridgeStore));
+ }
+ else if (this.opts.eventStore) {
+ storePromises.push(Promise.resolve(this.opts.eventStore));
+ }
+
+ // This works because if they provided a string we converted it to a Promise
+ // which will be resolved when we have the db instance. If they provided a
+ // db instance then this will resolve immediately.
+ const [userStore, roomStore, eventStore] = await Promise.all(storePromises);
+ this.userStore = userStore as UserBridgeStore;
+ this.roomStore = roomStore as RoomBridgeStore;
+ this.eventStore = eventStore as EventBridgeStore;
+ }
+
+/**
+ * Run the bridge (start listening)
+ * @param port The port to listen on.
+ * @param config Configuration options
+ * @param appServiceInstance The AppService instance to attach to.
+ * If not provided, one will be created.
+ * @param hostname Optional hostname to bind to. (e.g. 0.0.0.0)
+ * @return A promise resolving when the bridge is ready
+ */
+ public async run(port: number, config: T, appServiceInstance?: AppService, hostname?: string, backlog = 10) {
+ // Load the registration file into an AppServiceRegistration object.
+ if (typeof this.opts.registration === "string") {
+ const regObj = yaml.safeLoad(fs.readFileSync(this.opts.registration, 'utf8'));
+ const registration = AppServiceRegistration.fromObject(regObj);
+ if (registration === null) {
+ throw Error("Failed to parse registration file");
+ }
+ this.registration = registration;
+ }
+ else if (this.opts.registration instanceof AppServiceRegistration) {
+ this.registration = this.opts.registration;
+ }
+ else {
+ throw Error('Invalid opts.registration provided');
+ }
+
+ const asToken = this.registration.getAppServiceToken();
+ if (!asToken) {
+ throw Error('No AS token provided, cannot create ClientFactory');
+ }
+
+ this.clientFactory = this.opts.clientFactory || new ClientFactory({
+ url: this.opts.homeserverUrl,
+ token: asToken,
+ appServiceUserId: `@${this.registration.getSenderLocalpart()}:${this.opts.domain}`,
+ clientSchedulerBuilder: function() {
+ return new MatrixScheduler(retryAlgorithm, queueAlgorithm);
+ },
+ });
+ this.clientFactory.setLogFunction((text, isErr) => {
+ this.onLog(text, isErr);
+ });
+ this.botClient = this.clientFactory.getClientAs();
+ this.appServiceBot = new AppServiceBot(
+ this.botClient, this.registration, this.membershipCache,
+ );
+
+ if (this.opts.roomLinkValidation !== undefined) {
+ this.roomLinkValidator = new RoomLinkValidator(
+ this.opts.roomLinkValidation,
+ this.appServiceBot,
+ );
+ }
+
+ this.requestFactory = new RequestFactory();
+ if (this.opts.logRequestOutcome) {
+ this.requestFactory.addDefaultResolveCallback((req) =>
+ this.onLog(
+ "[" + req.getId() + "] SUCCESS (" + req.getDuration() + "ms)"
+ )
+ );
+ this.requestFactory.addDefaultRejectCallback((req, err) =>
+ this.onLog(
+ "[" + req.getId() + "] FAILED (" + req.getDuration() + "ms) " +
+ (err ? util.inspect(err) : "")
+ )
+ );
+ }
+ const botIntentOpts: IntentOpts = {
+ registered: true,
+ backingStore: this.intentBackingStore,
+ ...this.opts.intentOptions?.bot, // copy across opts, if defined
+ };
+
+ this.botIntent = new Intent(this.botClient, this.botClient, botIntentOpts);
+
+ const homeserverToken = this.registration.getHomeserverToken();
+ if (!homeserverToken) {
+ throw Error('No HS token provided, cannot create AppService');
+ }
+
+ this.appservice = appServiceInstance || new AppService({
+ homeserverToken,
+ });
+ this.appservice.onUserQuery = (userId) => this.onUserQuery(userId);
+ this.appservice.onAliasQuery = this.onAliasQuery.bind(this);
+ this.appservice.on("event", this.onEvent.bind(this));
+ this.appservice.on("http-log", (line) => {
+ this.onLog(line, false);
+ });
+ this.customiseAppservice();
+ this.setupIntentCulling();
+
+ if (this.metrics) {
+ this.metrics.addAppServicePath(this);
+ }
+
+ await this.loadDatabases();
+ await this.appservice.listen(port, hostname || "0.0.0.0", backlog);
+ }
+
+ /**
+ * Apply any customisations required on the appService object.
+ */
+ private customiseAppservice() {
+ this.customiseAppserviceThirdPartyLookup();
+ if (this.opts.roomLinkValidation && this.opts.roomLinkValidation.triggerEndpoint) {
+ this.addAppServicePath({
+ method: "POST",
+ path: "/_bridge/roomLinkValidator/reload",
+ handler: (req, res) => {
+ try {
+ // Will use filename if provided, or the config
+ // one otherwised.
+ if (this.roomLinkValidator) {
+ this.roomLinkValidator?.readRuleFile(req.query.filename as string|undefined);
+ res.status(200).send("Success");
+ }
+ else {
+ res.status(404).send("RoomLinkValidator not in use");
+ }
+ }
+ catch (e) {
+ res.status(500).send("Failed: " + e);
+ }
+ },
+ });
+ }
+ }
+
+// Set a timer going which will periodically remove Intent objects to prevent
+// them from accumulating too much. Removal is based on access time (calls to
+// getIntent). Intents expire after `INTENT_CULL_EVICT_AFTER_MS` of not being called.
+ private setupIntentCulling() {
+ if (this.intentLastAccessedTimeout) {
+ clearTimeout(this.intentLastAccessedTimeout);
+ }
+ this.intentLastAccessedTimeout = setTimeout(() => {
+ const now = Date.now();
+ for (const [key, entry] of this.intents.entries()) {
+ if (entry.lastAccessed + INTENT_CULL_EVICT_AFTER_MS < now) {
+ this.intents.delete(key);
+ }
+ }
+ this.intentLastAccessedTimeout = null;
+ // repeat forever. We have no cancellation mechanism but we don't expect
+ // Bridge objects to be continually recycled so this is fine.
+ this.setupIntentCulling();
+ }, INTENT_CULL_CHECK_PERIOD_MS);
+ }
+
+ private customiseAppserviceThirdPartyLookup() {
+ const lookupController = this.opts.controller.thirdPartyLookup;
+ if (!lookupController) {
+ // Nothing to do.
+ return;
+ }
+ const protocols = lookupController.protocols || [];
+
+ const respondErr = function(e: {code?: number, err?: string}, res: ExResponse) {
+ if (e.code && e.err) {
+ res.status(e.code).json({error: e.err});
+ }
+ else {
+ res.status(500).send("Failed: " + e);
+ }
+ }
+
+ if (lookupController.getProtocol) {
+ const getProtocolFunc = lookupController.getProtocol;
+
+ this.addAppServicePath({
+ method: "GET",
+ path: "/_matrix/app/:version(v1|unstable)/thirdparty/protocol/:protocol",
+ checkToken: this.opts.authenticateThirdpartyEndpoints,
+ handler: async (req, res) => {
+ const protocol = req.params.protocol;
+
+ if (protocols.length && protocols.indexOf(protocol) === -1) {
+ res.status(404).json({err: "Unknown 3PN protocol " + protocol});
+ return;
+ }
+
+ try {
+ const result = await getProtocolFunc(protocol);
+ res.status(200).json(result);
+ }
+ catch (ex) {
+ respondErr(ex, res)
+ }
+ },
+ });
+ }
+
+ if (lookupController.getLocation) {
+ const getLocationFunc = lookupController.getLocation;
+
+ this.addAppServicePath({
+ method: "GET",
+ path: "/_matrix/app/:version(v1|unstable)/thirdparty/location/:protocol",
+ checkToken: this.opts.authenticateThirdpartyEndpoints,
+ handler: async (req, res) => {
+ const protocol = req.params.protocol;
+
+ if (protocols.length && protocols.indexOf(protocol) === -1) {
+ res.status(404).json({err: "Unknown 3PN protocol " + protocol});
+ return;
+ }
+
+ // Do not leak access token to function
+ delete req.query.access_token;
+
+ try {
+ const result = await getLocationFunc(protocol, req.query as Record);
+ res.status(200).json(result);
+ }
+ catch (ex) {
+ respondErr(ex, res)
+ }
+ },
+ });
+ }
+
+ if (lookupController.parseLocation) {
+ const parseLocationFunc = lookupController.parseLocation;
+
+ this.addAppServicePath({
+ method: "GET",
+ path: "/_matrix/app/:version(v1|unstable)/thirdparty/location",
+ checkToken: this.opts.authenticateThirdpartyEndpoints,
+ handler: async (req, res) => {
+ const alias = req.query.alias;
+ if (!alias) {
+ res.status(400).send({err: "Missing 'alias' parameter"});
+ return;
+ }
+ if (typeof alias !== "string") {
+ res.status(400).send({err: "'alias' must be a string"});
+ return;
+ }
+
+ try {
+ const result = await parseLocationFunc(alias);
+ res.status(200).json(result);
+ }
+ catch (ex) {
+ respondErr(ex, res)
+ }
+ },
+ });
+ }
+
+ if (lookupController.getUser) {
+ const getUserFunc = lookupController.getUser;
+
+ this.addAppServicePath({
+ method: "GET",
+ path: "/_matrix/app/:version(v1|unstable)/thirdparty/user/:protocol",
+ checkToken: this.opts.authenticateThirdpartyEndpoints,
+ handler: async (req, res) => {
+ const protocol = req.params.protocol;
+
+ if (protocols.length && protocols.indexOf(protocol) === -1) {
+ res.status(404).json({err: "Unknown 3PN protocol " + protocol});
+ return;
+ }
+
+ // Do not leak access token to function
+ delete req.query.access_token;
+
+ try {
+ const result = await getUserFunc(protocol, req.query as Record);
+ res.status(200).json(result);
+ }
+ catch (ex) {
+ respondErr(ex, res)
+ }
+ }
+ });
+ }
+
+ if (lookupController.parseUser) {
+ const parseUserFunc = lookupController.parseUser;
+
+ this.addAppServicePath({
+ method: "GET",
+ path: "/_matrix/app/:version(v1|unstable)/thirdparty/user",
+ checkToken: this.opts.authenticateThirdpartyEndpoints,
+ handler: async (req, res) => {
+ const userid = req.query.userid;
+ if (!userid) {
+ res.status(400).send({err: "Missing 'userid' parameter"});
+ return;
+ }
+ if (typeof userid !== "string") {
+ res.status(400).send({err: "'userid' must be a string"});
+ return;
+ }
+
+ try {
+ const result = await parseUserFunc(userid);
+ res.status(200).json(result);
+ }
+ catch (ex) {
+ respondErr(ex, res)
+ }
+ },
+ });
+ }
+ }
+
+ /**
+ * Install a custom handler for an incoming HTTP API request. This allows
+ * callers to add extra functionality, implement new APIs, etc...
+ * @param {Object} opts Named options
+ * @param {string} opts.method The HTTP method name.
+ * @param {string} opts.path Path to the endpoint.
+ * @param {string} opts.checkToken Should the token be automatically checked. Defaults to true.
+ * @param {Bridge~appServicePathHandler} opts.handler Function to handle requests
+ * to this endpoint.
+ */
+ public addAppServicePath(opts: {method: "GET"|"PUT"|"POST"|"DELETE", checkToken?: boolean, path: string, handler: (req: ExRequest, respose: ExResponse, next: NextFunction) => void}) {
+ // TODO(paul): This is gut-wrenching into the AppService instance itself.
+ // Maybe an API on that object would be good?
+ const app: Application = (this.appservice as any).app;
+ opts.checkToken = opts.checkToken !== undefined ? opts.checkToken : true;
+ // TODO(paul): Consider more options:
+ // opts.versions - automatic version filtering and rejecting of
+ // unrecognised API versions
+ // Consider automatic "/_matrix/app/:version(v1|unstable)" path prefix
+ app[opts.method.toLowerCase() as "get"|"put"|"post"|"delete"](opts.path, (req, res, ...args) => {
+ if (opts.checkToken && !this.requestCheckToken(req)) {
+ return res.status(403).send({
+ errcode: "M_FORBIDDEN",
+ error: "Bad token supplied,"
+ });
+ }
+ return opts.handler(req, res, ...args);
+ });
+ }
+
+ /**
+ * Retrieve the connected room store instance.
+ */
+ public getRoomStore() {
+ return this.roomStore;
+ }
+
+ /**
+ * Retrieve the connected user store instance.
+ */
+ public getUserStore() {
+ return this.userStore;
+ }
+
+ /**
+ * Retrieve the connected event store instance, if one was configured.
+ */
+ public getEventStore() {
+ return this.eventStore;
+ }
+
+ /**
+ * Retrieve the request factory used to create incoming requests.
+ */
+ public getRequestFactory() {
+ return this.requestFactory;
+ }
+
+ /**
+ * Retrieve the matrix client factory used when sending matrix requests.
+ */
+ public getClientFactory() {
+ return this.clientFactory;
+ }
+
+ /**
+ * Get the AS bot instance.
+ */
+ public getBot() {
+ return this.appServiceBot;
+ }
+
+ /**
+ * Determines whether a room should be provisoned based on
+ * user provided rules and the room state. Will default to true
+ * if no rules have been provided.
+ * @param roomId The room to check.
+ * @param cache Should the validator check it's cache.
+ * @returns resolves if can and rejects if it cannot.
+ * A status code is returned on both.
+ */
+ public async canProvisionRoom(roomId: string, cache=true) {
+ if (!this.roomLinkValidator) {
+ return RoomLinkValidatorStatus.PASSED;
+ }
+ return this.roomLinkValidator.validateRoom(roomId, cache);
+ }
+
+ public getRoomLinkValidator() {
+ return this.roomLinkValidator;
+ }
+
+ /**
+ * Retrieve an Intent instance for the specified user ID. If no ID is given, an
+ * instance for the bot itself is returned.
+ * @param userId The user ID to get an Intent for.
+ * @param request Optional. The request instance to tie the MatrixClient
+ * instance to. Useful for logging contextual request IDs.
+ * @return The intent instance
+ */
+ public getIntent(userId: string, request?: Request) {
+ if (!this.clientFactory) {
+ throw Error('Cannot call getIntent before calling .run()');
+ }
+ if (!userId) {
+ if (!this.botIntent) {
+ // This will be defined when .run is called.
+ throw Error('Cannot call getIntent before calling .run()');
+ }
+ return this.botIntent;
+ }
+ if (this.opts.escapeUserIds === undefined || this.opts.escapeUserIds) {
+ userId = new MatrixUser(userId).getId(); // Escape the ID
+ }
+
+ const key = userId + (request ? request.getId() : "");
+ const existingIntent = this.intents.get(key);
+ if (existingIntent) {
+ existingIntent.lastAccessed = Date.now();
+ return existingIntent.intent;
+ }
+
+ const client = this.clientFactory.getClientAs(userId, request);
+ const clientIntentOpts: IntentOpts = {
+ backingStore: this.intentBackingStore,
+ ...this.opts.intentOptions?.clients,
+ };
+ clientIntentOpts.registered = this.membershipCache.isUserRegistered(userId);
+ const intent = new Intent(client, this.botClient, clientIntentOpts);
+ this.intents.set(key, { intent, lastAccessed: Date.now() });
+
+ return intent;
+ }
+
+ /**
+ * Retrieve an Intent instance for the specified user ID localpart. This must
+ * be the complete user localpart.
+ * @param localpart The user ID localpart to get an Intent for.
+ * @param request Optional. The request instance to tie the MatrixClient
+ * instance to. Useful for logging contextual request IDs.
+ * @return The intent instance
+ */
+ public getIntentFromLocalpart(localpart: string, request?: Request) {
+ return this.getIntent(
+ "@" + localpart + ":" + this.opts.domain, request,
+ );
+ }
+
+
+ /**
+ * Provision a user on the homeserver.
+ * @param matrixUser The virtual user to be provisioned.
+ * @param provisionedUser Provisioning information.
+ * @return Resolved when provisioned.
+ */
+ public async provisionUser(matrixUser: MatrixUser, provisionedUser?: {name?: string, url?: string, remote?: RemoteUser}) {
+ if (!this.clientFactory) {
+ throw Error('Cannot call getIntent before calling .run()');
+ }
+ await this.botClient.register(matrixUser.localpart);
+
+ if (!this.opts.disableStores) {
+ if (!this.userStore) {
+ throw Error('Trued to call provisionUser before databases were loaded');
+ }
+ await this.userStore.setMatrixUser(matrixUser);
+ if (provisionedUser?.remote) {
+ await this.userStore.linkUsers(matrixUser, provisionedUser.remote);
+ }
+ }
+ const userClient = this.clientFactory.getClientAs(matrixUser.getId());
+ if (provisionedUser?.name) {
+ await userClient.setDisplayName(provisionedUser.name);
+ }
+ if (provisionedUser?.url) {
+ await userClient.setAvatarUrl(provisionedUser.url);
+ }
+ }
+
+ private async onUserQuery(userId: string) {
+ if (!this.opts.controller.onUserQuery) {
+ return;
+ }
+ const matrixUser = new MatrixUser(userId);
+ try {
+ const provisionedUser = await this.opts.controller.onUserQuery(matrixUser);
+ if (!provisionedUser) {
+ log.warn(`Not provisioning user for ${userId}`);
+ return;
+ }
+ await this.provisionUser(matrixUser, provisionedUser);
+ }
+ catch (ex) {
+ log.error(`Failed _onUserQuery for ${userId}`, ex);
+ }
+ }
+
+ private async onAliasQuery(alias: string) {
+ if (!this.opts.controller.onAliasQuery) {
+ return;
+ }
+ if (!this.opts.controller.onUserQuery) {
+ return;
+ }
+ if (!this.botIntent) {
+ throw Error('botIntent is not ready yet');
+ return;
+ }
+ const aliasLocalpart = alias.split(":")[0].substring(1);
+ const provisionedRoom = await this.opts.controller.onAliasQuery(alias, aliasLocalpart);
+ const createRoomResponse: {room_id: string} = await this.botClient.createRoom(
+ provisionedRoom.creationOpts
+ );
+ const roomId = createRoomResponse.room_id;
+ if (!this.opts.disableStores) {
+ if (!this.roomStore) {
+ throw Error("roomStore is not ready yet");
+ }
+ const matrixRoom = new MatrixRoom(roomId);
+ const remoteRoom = provisionedRoom.remote;
+ if (remoteRoom) {
+ await this.roomStore.linkRooms(matrixRoom, remoteRoom, {});
+ }
+ else {
+ // store the matrix room only
+ await this.roomStore.setMatrixRoom(matrixRoom);
+ }
+ }
+ if (this.opts.controller.onAliasQueried) {
+ await this.opts.controller.onAliasQueried(alias, roomId);
+ }
+ }
+
+ // returns a Promise for the request linked to this event for testing.
+ private async onEvent(event: WeakEvent) {
+ if (!this.registration) {
+ // Called before we were ready, which is probably impossible.
+ return null;
+ }
+ const isCanonicalState = event.state_key === "";
+ this.updateIntents(event);
+ if (this.opts.suppressEcho &&
+ this.registration.isUserMatch(event.sender, true)) {
+ return null;
+ }
+
+ if (this.roomUpgradeHandler && this.appServiceBot) {
+ // m.room.tombstone is the event that signals a room upgrade.
+ if (event.type === "m.room.tombstone" && isCanonicalState && this.roomUpgradeHandler) {
+ this.roomUpgradeHandler.onTombstone({...event, content: event.content as {replacement_room: string}});
+ if (this.opts.roomUpgradeOpts.consumeEvent) {
+ return null;
+ }
+ }
+ else if (event.type === "m.room.member" &&
+ event.state_key === this.appServiceBot.getUserId() &&
+ (event.content as {membership: UserMembership}).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 = await this.roomUpgradeHandler.onInvite(event);
+ if (isUpgradeInvite &&
+ this.opts.roomUpgradeOpts.consumeEvent) {
+ return null;
+ }
+ }
+ }
+
+ const request = this.requestFactory.newRequest({ data: event });
+ const contextReady = this.getBridgeContext(event);
+ const dataReady = contextReady.then(context => ({ request, context }));
+
+ const dataReadyLimited = this.limited(dataReady, request);
+
+ this.queue.push(event, dataReadyLimited);
+ this.queue.consume();
+ const reqPromise = request.getPromise();
+
+ // We *must* return the result of the request.
+ try {
+ return await reqPromise;
+ }
+ catch (ex) {
+ if (ex instanceof EventNotHandledError) {
+ this.handleEventError(event, ex);
+ }
+ }
+ }
+
+ /**
+ * Restricts the promise according to the bridges `perRequest` setting.
+ *
+ * `perRequest` enabled:
+ * Returns a promise similar to `promise`, with the addition of it only
+ * resolving after `request`.
+ * `perRequest` disabled:
+ * Returns the promise unchanged.
+ */
+ private async limited(promise: Promise, request: Request): Promise {
+ // queue.perRequest controls whether multiple request can be processed by
+ // the bridge at once.
+ if (this.opts.queue?.perRequest) {
+ const promiseLimited = (async () => {
+ try {
+ // We don't care about the results
+ await this.prevRequestPromise;
+ }
+ finally {
+ return promise;
+ }
+ })();
+ this.prevRequestPromise = promiseLimited;
+ return promiseLimited;
+ }
+
+ return promise;
+ }
+
+ private onConsume(err: Error|null, data: { request: Request, context?: BridgeContext}) {
+ if (err) {
+ // The data for the event could not be retrieved.
+ this.onLog("onEvent failure: " + err, true);
+ return;
+ }
+
+ this.opts.controller.onEvent(data.request, data.context);
+ }
+
+ private async getBridgeContext(event: {sender: string, type: string, state_key: string, room_id: string}) {
+ if (this.opts.disableContext) {
+ return null;
+ }
+
+ if (!this.roomStore || !this.userStore) {
+ throw Error('Cannot call getBridgeContext before loading databases');
+ }
+
+ const context = new BridgeContext({
+ sender: event.sender,
+ target: event.type === "m.room.member" ? event.state_key : undefined,
+ room: event.room_id
+ });
+
+ return context.get(this.roomStore, this.userStore);
+ }
+
+ private handleEventError(event: {room_id: string, event_id: string}, error: EventNotHandledError) {
+ if (!this.botIntent) {
+ throw Error('Cannot call handleEventError before calling .run()');
+ }
+ if (!(error instanceof EventNotHandledError)) {
+ error = wrapError(error, BridgeInternalError);
+ }
+ // TODO[V02460@gmail.com]: Send via different means when the bridge bot is
+ // unavailable. _MSC2162: Signaling Errors at Bridges_ will have details on
+ // how this should be done.
+ this.botIntent.unstableSignalBridgeError(
+ event.room_id,
+ event.event_id,
+ this.opts.networkName,
+ error.reason,
+ this.getUserRegex(),
+ );
+ }
+
+ /**
+ * Returns a regex matching all users of the bridge.
+ * @return Super regex composed of all user regexes.
+ */
+ private getUserRegex(): string[] {
+ // Return empty array if registration isn't available yet.
+ return this.registration?.getOutput().namespaces.users.map(o => o.regex) || [];
+ }
+
+ private updateIntents(event: WeakEvent) {
+ if (event.type === "m.room.member") {
+ const content = event.content as { membership: UserMembership };
+ this.membershipCache.setMemberEntry(
+ event.room_id,
+ event.state_key,
+ content ? content.membership : null
+ );
+ }
+ else if (event.type === "m.room.power_levels") {
+ const content = event.content as PowerLevelContent;
+ this.setPowerLevelEntry(event.room_id, content);
+ }
+ }
+
+ private setPowerLevelEntry(roomId: string, content: PowerLevelContent) {
+ this.powerlevelMap.set(roomId, content);
+ }
+
+ private getPowerLevelEntry(roomId: string) {
+ return this.powerlevelMap.get(roomId);
+ }
+
+ /**
+ * Returns a PrometheusMetrics instance stored on the bridge, creating it first
+ * if required. The instance will be registered with the HTTP server so it can
+ * serve the "/metrics" page in the usual way.
+ * The instance will automatically register the Matrix SDK metrics by calling
+ * {PrometheusMetrics~registerMatrixSdkMetrics}.
+ * @param {boolean} registerEndpoint Register the /metrics endpoint on the appservice HTTP server. Defaults to true.
+ * @param {Registry?} registry Optionally provide an alternative registry for metrics.
+ */
+ public getPrometheusMetrics(registerEndpoint = true, registry = undefined): PrometheusMetrics {
+ if (this.metrics) {
+ return this.metrics;
+ }
+
+ const metrics = this.metrics = new PrometheusMetrics(registry);
+
+ metrics.registerMatrixSdkMetrics();
+
+ // TODO(paul): register some bridge-wide standard ones here
+
+ // In case we're called after .run()
+ if (this.appService && registerEndpoint) {
+ metrics.addAppServicePath(this);
+ }
+
+ return metrics;
+ }
+
+ /**
+ * A convenient shortcut to calling registerBridgeGauges() on the
+ * PrometheusMetrics instance directly. This version will supply the value of
+ * the matrixGhosts field if the counter function did not return it, for
+ * convenience.
+ * @param {PrometheusMetrics~BridgeGaugesCallback} counterFunc A function that
+ * when invoked returns the current counts of various items in the bridge.
+ *
+ * @example
+ * bridge.registerBridgeGauges(() => {
+ * return {
+ * matrixRoomConfigs: Object.keys(this.matrixRooms).length,
+ * remoteRoomConfigs: Object.keys(this.remoteRooms).length,
+ *
+ * remoteGhosts: Object.keys(this.remoteGhosts).length,
+ *
+ * ...
+ * }
+ * })
+ */
+ public registerBridgeGauges(counterFunc: () => BridgeGaugesCounts) {
+ this.getPrometheusMetrics().registerBridgeGauges(() => {
+ const counts = counterFunc();
+ if (counts.matrixGhosts !== undefined) {
+ counts.matrixGhosts = Object.keys(this.intents.size).length;
+ }
+ return counts;
+ });
+ }
+
+ /**
+ * Check a express Request to see if it's correctly
+ * authenticated (includes the hsToken). The query parameter `access_token`
+ * and the `Authorization` header are checked.
+ * @returns {Boolean} True if authenticated, False if not.
+ */
+ public requestCheckToken(req: ExRequest) {
+ if (!this.registration) {
+ // Bridge isn't ready yet
+ return false;
+ }
+ if (
+ req.query.access_token !== this.registration.getHomeserverToken() &&
+ req.get("authorization") !== `Bearer ${this.registration.getHomeserverToken()}`
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+}
+
+function loadDatabase(path: string, cls: new (db: Datastore) => T) {
+ const defer = deferPromise();
+ var db = new Datastore({
+ filename: path,
+ autoload: true,
+ onload: function(err) {
+ if (err) {
+ defer.reject(err);
+ }
+ else {
+ defer.resolve(new cls(db));
+ }
+ }
+ });
+ return defer.promise;
+}
+
+function retryAlgorithm(event: unknown, attempts: number, err: {httpStatus: number, cors?: string, name: string, data?: { retry_after_ms: number }}) {
+ if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) {
+ // client error; no amount of retrying with save you now.
+ return -1;
+ }
+ // we ship with browser-request which returns { cors: rejected } when trying
+ // with no connection, so if we match that, give up since they have no conn.
+ if (err.cors === "rejected") {
+ return -1;
+ }
+
+ if (err.name === "M_LIMIT_EXCEEDED") {
+ const waitTime = err.data?.retry_after_ms;
+ if (waitTime) {
+ return waitTime;
+ }
+ }
+ if (attempts > 4) {
+ return -1; // give up
+ }
+ return 1000 + (1000 * attempts);
+}
+
+function queueAlgorithm(event: {getType: () => string, getRoomId(): string}) {
+ if (event.getType() === "m.room.message") {
+ // use a separate queue for each room ID
+ return "message_" + event.getRoomId();
+ }
+ // allow all other events continue concurrently.
+ return null;
+}
+
+/**
+ * @typedef Bridge~ProvisionedUser
+ * @type {Object}
+ * @property {string=} name The display name to set for the provisioned user.
+ * @property {string=} url The avatar URL to set for the provisioned user.
+ * @property {RemoteUser=} remote The remote user to link to the provisioned user.
+ */
+
+/**
+ * @typedef Bridge~ProvisionedRoom
+ * @type {Object}
+ * @property {Object} creationOpts Room creation options to use when creating the
+ * room. Required.
+ * @property {RemoteRoom=} remote The remote room to link to the provisioned room.
+ */
+
+/**
+ * Invoked when the bridge receives a user query from the homeserver. Supports
+ * both sync return values and async return values via promises.
+ * @callback Bridge~onUserQuery
+ * @param {MatrixUser} matrixUser The matrix user queried. Use getId()
+ * to get the user ID.
+ * @return {?Bridge~ProvisionedUser|Promise}
+ * Reject the promise / return null to not provision the user. Resolve the
+ * promise / return a {@link Bridge~ProvisionedUser} object to provision the user.
+ * @example
+ * new Bridge({
+ * controller: {
+ * onUserQuery: function(matrixUser) {
+ * var remoteUser = new RemoteUser("some_remote_id");
+ * return {
+ * name: matrixUser.localpart + " (Bridged)",
+ * url: "http://someurl.com/pic.jpg",
+ * user: remoteUser
+ * };
+ * }
+ * }
+ * });
+ */
+
+/**
+ * Invoked when the bridge receives an alias query from the homeserver. Supports
+ * both sync return values and async return values via promises.
+ * @callback Bridge~onAliasQuery
+ * @param {string} alias The alias queried.
+ * @param {string} aliasLocalpart The parsed localpart of the alias.
+ * @return {?Bridge~ProvisionedRoom|Promise}
+ * Reject the promise / return null to not provision the room. Resolve the
+ * promise / return a {@link Bridge~ProvisionedRoom} object to provision the room.
+ * @example
+ * new Bridge({
+ * controller: {
+ * onAliasQuery: function(alias, aliasLocalpart) {
+ * return {
+ * creationOpts: {
+ * room_alias_name: aliasLocalpart, // IMPORTANT: must be set to make the link
+ * name: aliasLocalpart,
+ * topic: "Auto-generated bridged room"
+ * }
+ * };
+ * }
+ * }
+ * });
+ */
+
+ /**
+ * Invoked when a response is returned from onAliasQuery. Supports
+ * both sync return values and async return values via promises.
+ * @callback Bridge~onAliasQueried
+ * @param {string} alias The alias queried.
+ * @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
+ * @param {Request} request The request to resolve or reject depending on the
+ * outcome of this request. The 'data' attached to this Request is the raw event
+ * JSON received (accessed via request.getData()
)
+ * @param {Bridge~BridgeContext} context Context for this event, including
+ * instantiated client instances.
+ */
+
+ /**
+ * Invoked when the bridge is attempting to log something.
+ * @callback Bridge~onLog
+ * @param {string} line The text to be logged.
+ * @param {boolean} isError True if this line should be treated as an error msg.
+ */
+
+ /**
+ * Handler function for custom applied HTTP API request paths. This is invoked
+ * as defined by expressjs.
+ * @callback Bridge~appServicePathHandler
+ * @param {Request} req An expressjs Request object the handler can use to
+ * inspect the incoming request.
+ * @param {Response} res An expressjs Response object the handler can use to
+ * send the outgoing response.
+ */
+
+ /**
+ * @typedef Bridge~thirdPartyLookup
+ * @type {Object}
+ * @property {string[]} protocols Optional list of recognised protocol names.
+ * If present, lookups for unrecognised protocols will be automatically
+ * rejected.
+ * @property {Bridge~getProtocol} getProtocol Function. Called for requests
+ * for 3PE query metadata.
+ * @property {Bridge~getLocation} getLocation Function. Called for requests
+ * for 3PLs.
+ * @property {Bridge~parseLocation} parseLocation Function. Called for reverse
+ * parse requests on 3PL aliases.
+ * @property {Bridge~getUser} getUser Function. Called for requests for 3PUs.
+ * @property {Bridge~parseUser} parseUser Function. Called for reverse parse
+ * requests on 3PU user IDs.
+ */
+
+ /**
+ * Invoked on requests for 3PE query metadata
+ * @callback Bridge~getProtocol
+ * @param {string} protocol The name of the 3PE protocol to query
+ * @return {Promise} A Promise of metadata
+ * about 3PE queries that can be made for this protocol.
+ */
+
+ /**
+ * Returned by getProtocol third-party query metadata requests
+ * @typedef Bridge~thirdPartyProtocolResult
+ * @type {Object}
+ * @property {string[]} [location_fields] Names of the fields required for
+ * location lookups if location queries are supported.
+ * @property {string[]} [user_fields] Names of the fields required for user
+ * lookups if user queries are supported.
+
+ /**
+ * Invoked on requests for 3PLs
+ * @callback Bridge~getLocation
+ * @param {string} protocol The name of the 3PE protocol
+ * @param {Object} fields The location query field data as specified by the
+ * specific protocol.
+ * @return {Promise} A Promise of a list of
+ * 3PL lookup results.
+ */
+
+ /**
+ * Invoked on requests to parse 3PL aliases
+ * @callback Bridge~parseLocation
+ * @param {string} alias The room alias to parse.
+ * @return {Promise} A Promise of a list of
+ * 3PL lookup results.
+ */
+
+ /**
+ * Returned by getLocation and parseLocation third-party location lookups
+ * @typedef Bridge~thirdPartyLocationResult
+ * @type {Object}
+ * @property {string} alias The Matrix room alias to the portal room
+ * representing this 3PL
+ * @property {string} protocol The name of the 3PE protocol
+ * @property {object} fields The normalised values of the location query field
+ * data.
+ */
+
+ /**
+ * Invoked on requests for 3PUs
+ * @callback Bridge~getUser
+ * @param {string} protocol The name of the 3PE protocol
+ * @param {Object} fields The user query field data as specified by the
+ * specific protocol.
+ * @return {Promise} A Promise of a list of 3PU
+ * lookup results.
+ */
+
+ /**
+ * Invoked on requests to parse 3PU user IDs
+ * @callback Bridge~parseUser
+ * @param {string} userid The user ID to parse.
+ * @return {Promise} A Promise of a list of 3PU
+ * lookup results.
+ */
+
+ /**
+ * Returned by getUser and parseUser third-party user lookups
+ * @typedef Bridge~thirdPartyUserResult
+ * @type {Object}
+ * @property {string} userid The Matrix user ID for the ghost representing
+ * this 3PU
+ * @property {string} protocol The name of the 3PE protocol
+ * @property {object} fields The normalised values of the user query field
+ * data.
+ */
diff --git a/src/components/bridge-context.ts b/src/components/bridge-context.ts
index 9222b9a7..9a8ad0e5 100644
--- a/src/components/bridge-context.ts
+++ b/src/components/bridge-context.ts
@@ -108,7 +108,7 @@ export class BridgeContext {
}
}
catch (ex) {
- throw unstable.wrapError(ex, Error, "Could not retrieve bridge context");
+ throw unstable.wrapError(ex, unstable.EventNotHandledError, "Could not retrieve bridge context");
}
return this;
}
diff --git a/src/components/cli.ts b/src/components/cli.ts
index dac52cae..c83d9de5 100644
--- a/src/components/cli.ts
+++ b/src/components/cli.ts
@@ -19,7 +19,7 @@ import path from "path";
import * as yaml from "js-yaml";
import nopt from "nopt";
import { AppServiceRegistration } from "matrix-appservice";
-import ConfigValidator from "./config-validator";
+import { ConfigValidator } from "./config-validator";
import * as logging from "./logging";
const DEFAULT_PORT = 8090;
@@ -206,7 +206,13 @@ export class Cli> {
if (!this.opts.bridgeConfig?.schema) {
return cfg as ConfigType;
}
- const validator = new ConfigValidator(this.opts.bridgeConfig.schema);
+ let validator: ConfigValidator;
+ if (typeof this.opts.bridgeConfig.schema === "string") {
+ validator = ConfigValidator.fromSchemaFile(this.opts.bridgeConfig.schema);
+ }
+ else {
+ validator = new ConfigValidator(this.opts.bridgeConfig.schema);
+ }
return validator.validate(cfg, this.opts.bridgeConfig.defaults) as ConfigType;
}
diff --git a/src/components/config-validator.ts b/src/components/config-validator.ts
index 727171d4..3629fa75 100644
--- a/src/components/config-validator.ts
+++ b/src/components/config-validator.ts
@@ -20,7 +20,7 @@ import extend from "extend";
type Schema = any;
-export default class ConfigValidator {
+export class ConfigValidator {
/**
* Construct a validator of YAML files.
@@ -67,9 +67,7 @@ export default class ConfigValidator {
return result;
}
- private static fromSchemaFile(filename: string): ConfigValidator {
+ public static fromSchemaFile(filename: string): ConfigValidator {
return new ConfigValidator(ConfigValidator.loadFromFile(filename));
}
}
-
-module.exports = ConfigValidator;
diff --git a/src/components/event-queue.ts b/src/components/event-queue.ts
index 5720624b..9ca5c832 100644
--- a/src/components/event-queue.ts
+++ b/src/components/event-queue.ts
@@ -42,9 +42,7 @@ export class EventQueue {
events: Array<{ dataReady: DataReady }>;
consuming: boolean;
}; } = {};
- constructor(private type: "none"|"single"|"per_room", protected consumeFn: ConsumeCallback) {
-
- }
+ constructor(private type: "none"|"single"|"per_room", protected consumeFn: ConsumeCallback) { }
/**
* Push the event and its related data to the queue.
diff --git a/src/components/intent.ts b/src/components/intent.ts
index 27d8e425..99940118 100644
--- a/src/components/intent.ts
+++ b/src/components/intent.ts
@@ -21,20 +21,18 @@ const { MatrixEvent, RoomMember } = JsSdk as any;
import { ClientRequestCache } from "./client-request-cache";
import { defer } from "../utils/promiseutil";
import { UserMembership } from "./membership-cache";
+import { unstable } from "../errors";
+import BridgeErrorReason = unstable.BridgeErrorReason;
-
-type BridgeErrorReason = "m.event_not_handled" | "m.event_too_old"
- | "m.internal_error" | "m.foreign_network_error" | "m.event_unknown";
-
-type BackingStore = {
+export type IntentBackingStore = {
getMembership: (roomId: string, userId: string) => UserMembership,
getPowerLevelContent: (roomId: string) => Record | undefined,
setMembership: (roomId: string, userId: string, membership: UserMembership) => void,
setPowerLevelContent: (roomId: string, content: Record) => void,
};
-interface IntentOpts {
- backingStore?: BackingStore,
+export interface IntentOpts {
+ backingStore?: IntentBackingStore,
caching?: {
ttl?: number,
size?: number,
@@ -45,6 +43,11 @@ interface IntentOpts {
registered?: boolean;
}
+export interface RoomCreationOpts {
+ createAsClient?: boolean;
+ options: Record;
+}
+
/**
* Returns the first parameter that is a number or 0.
*/
@@ -64,7 +67,7 @@ const STATE_EVENT_TYPES = [
const DEFAULT_CACHE_TTL = 90000;
const DEFAULT_CACHE_SIZE = 1024;
-type PowerLevelContent = {
+export type PowerLevelContent = {
// eslint-disable-next-line camelcase
state_default?: unknown;
// eslint-disable-next-line camelcase
@@ -86,7 +89,7 @@ export class Intent {
event: ClientRequestCache
}
private opts: {
- backingStore: BackingStore,
+ backingStore: IntentBackingStore,
caching: {
ttl: number,
size: number,
@@ -387,7 +390,8 @@ export class Intent {
* 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}) {
+ // eslint-disable-next-line camelcase
+ public async createRoom(opts: RoomCreationOpts): Promise<{room_id: string}> {
const cli = opts.createAsClient ? this.client : this.botClient;
const options = opts.options || {};
if (!opts.createAsClient) {
@@ -583,7 +587,7 @@ export class Intent {
public async unstableSignalBridgeError(
roomID: string,
eventID: string,
- networkName: string,
+ networkName: string|undefined,
reason: BridgeErrorReason,
affectedUsers: string[],
) {
diff --git a/src/components/prometheusmetrics.ts b/src/components/prometheusmetrics.ts
index 5691d822..6b0581c2 100644
--- a/src/components/prometheusmetrics.ts
+++ b/src/components/prometheusmetrics.ts
@@ -17,18 +17,17 @@ import PromClient, { Registry } from "prom-client";
import { AgeCounters } from "./agecounters";
import JsSdk from "matrix-js-sdk";
import { Request, Response } from "express";
-
type CollectorFunction = () => void;
-interface BridgeGaugesCounts {
- matrixRoomConfigs: number;
- remoteRoomConfigs: number;
- matrixGhosts: number;
- remoteGhosts: number;
- matrixRoomsByAge: AgeCounters;
- remoteRoomsByAge: AgeCounters;
- matrixUsersByAge: AgeCounters;
- remoteUsersByAge: AgeCounters;
+export interface BridgeGaugesCounts {
+ matrixRoomConfigs?: number;
+ remoteRoomConfigs?: number;
+ matrixGhosts?: number;
+ remoteGhosts?: number;
+ matrixRoomsByAge?: AgeCounters;
+ remoteRoomsByAge?: AgeCounters;
+ matrixUsersByAge?: AgeCounters;
+ remoteUsersByAge?: AgeCounters;
}
interface CounterOpts {
@@ -100,8 +99,8 @@ interface GagueOpts {
*
* @constructor
*/
-
export class PrometheusMetrics {
+ public static AgeCounters = AgeCounters;
private timers: {[name: string]: PromClient.Histogram} = {};
private counters: {[name: string]: PromClient.Counter} = {};
private collectors: CollectorFunction[] = [];
@@ -223,17 +222,19 @@ export class PrometheusMetrics {
this.addCollector(function () {
const counts = counterFunc();
- matrixRoomsGauge.set(counts.matrixRoomConfigs);
- remoteRoomsGauge.set(counts.remoteRoomConfigs);
+ if (counts.matrixRoomConfigs) {matrixRoomsGauge.set(counts.matrixRoomConfigs);}
+
+ if (counts.remoteRoomConfigs) {remoteRoomsGauge.set(counts.remoteRoomConfigs);}
+
+ if (counts.matrixGhosts) {matrixGhostsGauge.set(counts.matrixGhosts);}
- matrixGhostsGauge.set(counts.matrixGhosts);
- remoteGhostsGauge.set(counts.remoteGhosts);
+ if (counts.remoteGhosts) {remoteGhostsGauge.set(counts.remoteGhosts);}
- counts.matrixRoomsByAge.setGauge(matrixRoomsByAgeGauge);
- counts.remoteRoomsByAge.setGauge(remoteRoomsByAgeGauge);
+ counts.matrixRoomsByAge?.setGauge(matrixRoomsByAgeGauge);
+ counts.remoteRoomsByAge?.setGauge(remoteRoomsByAgeGauge);
- counts.matrixUsersByAge.setGauge(matrixUsersByAgeGauge);
- counts.remoteUsersByAge.setGauge(remoteUsersByAgeGauge);
+ counts.matrixUsersByAge?.setGauge(matrixUsersByAgeGauge);
+ counts.remoteUsersByAge?.setGauge(remoteUsersByAgeGauge);
});
}
diff --git a/src/components/request-factory.ts b/src/components/request-factory.ts
index 676d45d1..ca0d1b60 100644
--- a/src/components/request-factory.ts
+++ b/src/components/request-factory.ts
@@ -47,8 +47,7 @@ export class RequestFactory {
this._timeouts.forEach(function(timeoutObj) {
setTimeout(function() {
- const promise = req.getPromise();
- if (!promise.isPending()) {
+ if (!req.isPending) {
return;
}
timeoutObj.fn(req);
diff --git a/src/components/request.ts b/src/components/request.ts
index ace09101..5d4ee7e8 100644
--- a/src/components/request.ts
+++ b/src/components/request.ts
@@ -29,6 +29,11 @@ export class Request {
private data: T;
private startTs: number;
private defer: Defer;
+ private pending: boolean;
+
+ public get isPending(): boolean {
+ return this.pending;
+ }
/**
* Construct a new Request.
@@ -43,6 +48,7 @@ export class Request {
this.data = opts.data;
this.startTs = Date.now();
this.defer = defer();
+ this.pending = true;
}
@@ -86,6 +92,7 @@ export class Request {
* @param msg The thing to resolve with.
*/
public resolve(msg: unknown) {
+ this.pending = false;
this.defer.resolve(msg);
}
@@ -95,6 +102,7 @@ export class Request {
* @param msg The thing to reject with.
*/
public reject(msg: unknown) {
+ this.pending = false;
this.defer.reject(msg);
}
diff --git a/src/components/room-bridge-store.ts b/src/components/room-bridge-store.ts
index 357e7b5b..0644048b 100644
--- a/src/components/room-bridge-store.ts
+++ b/src/components/room-bridge-store.ts
@@ -179,8 +179,8 @@ export class RoomBridgeStore extends BridgeStore {
* @param linkId The id value to set. If not given, a unique ID will be
* created from the matrix_id and remote_id.
*/
- public linkRooms(matrixRoom: MatrixRoom, remoteRoom: RemoteRoom, data: Record, linkId: string) {
- data = data || {};
+ public linkRooms(matrixRoom: MatrixRoom, remoteRoom: RemoteRoom,
+ data: Record={}, linkId?: string) {
linkId = linkId || RoomBridgeStore.createUniqueId(
matrixRoom.getId(), remoteRoom.getId(), this.delimiter
);
diff --git a/src/components/room-link-validator.ts b/src/components/room-link-validator.ts
index 5dc6234a..da8ab9ec 100644
--- a/src/components/room-link-validator.ts
+++ b/src/components/room-link-validator.ts
@@ -18,14 +18,10 @@ limitations under the License.
*/
import util from "util";
import { AppServiceBot } from "./app-service-bot";
-import ConfigValidator from "./config-validator";
+import { ConfigValidator } from "./config-validator";
import logging from "./logging";
const log = logging.get("room-link-validator");
const VALIDATION_CACHE_LIFETIME = 30 * 60 * 1000;
-const PASSED = "RLV_PASSED";
-const ERROR = "RVL_ERROR";
-const ERROR_USER_CONFLICT = "RVL_USER_CONFLICT";
-const ERROR_CACHED = "RVL_ERROR_CACHED";
const RULE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
@@ -53,7 +49,7 @@ const RULE_SCHEMA = {
const VALIDATOR = new ConfigValidator(RULE_SCHEMA);
-interface Rules {
+export interface Rules {
userIds: {
exempt: RegExp[];
conflict: RegExp[];
@@ -66,7 +62,7 @@ interface Rules {
* in a seperate config from the bridge config. It can be reloaded by triggering
* an endpoint specified in the {@link Bridge} class.
*/
-class RoomLinkValidator {
+export class RoomLinkValidator {
private conflictCache: Map = new Map();
private ruleFile?: string;
public readonly rules: Rules; // Public to allow unit tests to inspect it.
@@ -78,7 +74,7 @@ class RoomLinkValidator {
* overwritten if both is set.
* @param asBot The AS bot.
*/
- constructor(config: {ruleFile?: string, rules: Rules}, private asBot: AppServiceBot) {
+ constructor(config: {ruleFile?: string, rules?: Rules}, private asBot: AppServiceBot) {
if (config.ruleFile) {
this.ruleFile = config.ruleFile;
this.rules = this.readRuleFile();
@@ -158,10 +154,10 @@ class RoomLinkValidator {
}
}
if (isValid) {
- return PASSED;
+ return RoomLinkValidatorStatus.PASSED;
}
this.conflictCache.set(roomId, Date.now());
- throw ERROR_USER_CONFLICT;
+ throw RoomLinkValidatorStatus.ERROR_USER_CONFLICT;
}
private checkConflictCache (roomId: string) {
@@ -170,19 +166,16 @@ class RoomLinkValidator {
return undefined;
}
if (cacheTime > (Date.now() - VALIDATION_CACHE_LIFETIME)) {
- return ERROR_CACHED;
+ return RoomLinkValidatorStatus.ERROR_CACHED;
}
this.conflictCache.delete(roomId);
return undefined;
}
}
-module.exports = {
- validationStatuses: {
- PASSED,
- ERROR_USER_CONFLICT,
- ERROR_CACHED,
- ERROR,
- },
- RoomLinkValidator
-};
+export enum RoomLinkValidatorStatus {
+ PASSED,
+ ERROR_USER_CONFLICT,
+ ERROR_CACHED,
+ ERROR,
+}
diff --git a/src/components/room-upgrade-handler.ts b/src/components/room-upgrade-handler.ts
index c6d534d1..af19319b 100644
--- a/src/components/room-upgrade-handler.ts
+++ b/src/components/room-upgrade-handler.ts
@@ -20,11 +20,42 @@ import { RoomBridgeStore, RoomBridgeStoreEntry } from "./room-bridge-store";
const log = logging.get("RoomUpgradeHandler");
-interface RoomUpgradeHandlerOpts {
+export interface RoomUpgradeHandlerOpts {
+ /**
+ * Should upgrade and invite events be processed after being handled
+ * by the RoomUpgradeHandler. Defaults to `false`.
+ */
+ consumeEvent: boolean;
+ /**
+ * Should ghost users be migrated to the new room. This will leave
+ * any users matching the user regex list in the registration file
+ * from the old room, and join them to the new room.
+ * Defaults to `true`
+ */
migrateGhosts: boolean;
+ /**
+ * Migrate room store entries automatically. Defaults to `true`
+ */
migrateStoreEntries: boolean;
+
+ /**
+ * Invoked after a room has been upgraded and it's entries updated.
+ *
+ * @param oldRoomId The old roomId.
+ * @param newRoomId The new roomId.
+ */
onRoomMigrated?: (oldRoomId: string, newRoomId: string) => Promise|void;
- migrateEntry?: (entry: RoomBridgeStoreEntry, newRoomId: string) => Promise;
+
+ /**
+ * Invoked when iterating around a rooms entries. Should be used to update entries
+ * with a new room id.
+ *
+ * @param entry The existing entry.
+ * @param newRoomId The new roomId.
+ * @return Return the entry to upsert it,
+ * or null to ignore it.
+ */
+ migrateEntry?: (entry: RoomBridgeStoreEntry, newRoomId: string) => Promise;
}
/**
@@ -45,6 +76,10 @@ export class RoomUpgradeHandler {
}
}
+ /**
+ * Called when the bridge sees a "m.room.tombstone" event.
+ * @param ev The m.room.tombstone event.
+ */
// eslint-disable-next-line camelcase
public async onTombstone(ev: {sender: string, room_id: string, content: {replacement_room: string}}) {
const movingTo = ev.content.replacement_room;
@@ -79,6 +114,16 @@ export class RoomUpgradeHandler {
}
}
+
+ /**
+ * Called when an invite event reaches the bridge. This function
+ * will check if the invite is from an upgraded room, and will
+ * join the room if so.
+ * @param ev A Matrix m.room.member event of membership=invite
+ * directed to the bridge bot
+ * @return True if the invite is from an upgraded room and shouldn't
+ * be processed.
+ */
// eslint-disable-next-line camelcase
public async onInvite(ev: {room_id: string}) {
const oldRoomId = this.waitingForInvite.get(ev.room_id);
@@ -170,52 +215,7 @@ export class RoomUpgradeHandler {
}
private migrateEntry(entry: RoomBridgeStoreEntry, newRoomId: string) {
- entry.matrix = new MatrixRoom(newRoomId, {
- name: entry.matrix?.name,
- topic: entry.matrix?.topic,
- extras: entry.matrix?.extras || {},
- });
+ entry.matrix = new MatrixRoom(newRoomId, entry.matrix?.serialize());
return entry;
}
}
-
-module.exports = RoomUpgradeHandler;
-
- /**
- * Options to supply to the {@link RoomUpgradeHandler}.
- * @typedef RoomUpgradeHandler~Options
- * @type {Object}
- * @property {RoomUpgradeHandler~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~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 true, migrate all ghost users across to
- * the new room.
- * @property {bool} [migrateStoreEntries=true] If true, migrate all ghost users across to
- * the new room.
- */
-
-
- /**
- * Invoked when iterating around a rooms entries. Should be used to update entries
- * with a new room id.
- *
- * @callback RoomUpgradeHandler~MigrateEntry
- * @param {RoomBridgeStore~Entry} entry The existing entry.
- * @param {string} newRoomId The new roomId.
- * @return {RoomBridgeStore~Entry} 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~onRoomMigrated
- * @param {string} oldRoomId The old roomId.
- * @param {string} newRoomId The new roomId.
- */
-
diff --git a/src/errors.ts b/src/errors.ts
index 52a84a8e..b5eebda1 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -16,17 +16,20 @@ limitations under the License.
let isFirstUseOfWrap = true;
export namespace unstable {
-
+
+
+ export type BridgeErrorReason = "m.event_not_handled" | "m.event_too_old"
+ | "m.internal_error" | "m.foreign_network_error" | "m.event_unknown";
/**
* Append the old error message to the new one and keep its stack trace.
* Example:
* throw wrapError(e, HighLevelError, "This error is more specific");
*/
- export function wrapError(
+ export function wrapError(
oldError: Error|string,
newErrorType: { new (message: string): T },
- message: string,
- ) {
+ message = "",
+ ): EventNotHandledError {
const newError = new newErrorType(message);
let appendMsg;
if (oldError instanceof Error) {
@@ -39,17 +42,17 @@ export namespace unstable {
newError.message += ":\n" + appendMsg;
return newError;
}
-
+
/**
* @deprecated Use {@link wrapError}
*/
- export function wrap(
+ export function wrap(
oldError: Error|string,
newErrorType: { new (message: string): T },
- message: string) {
+ message?: string) {
if (isFirstUseOfWrap) {
console.warn("matrix-appservice-bridge: Use of `unstable.wrap` is deprecated. Please use `unstable.wrapError`.")
- isFirstUseOfWrap = false;
+ isFirstUseOfWrap = false;
}
return wrapError(oldError, newErrorType, message);
}
@@ -58,11 +61,15 @@ export namespace unstable {
* Base Error for when the bride can not handle the event.
*/
export class EventNotHandledError extends Error {
- protected reason: string;
+ protected internalReason: BridgeErrorReason;
constructor(message="The event could not be handled by the bridge") {
super(message);
this.name = "EventNotHandledError";
- this.reason = "m.event_not_handled";
+ this.internalReason = "m.event_not_handled";
+ }
+
+ public get reason() {
+ return this.internalReason;
}
}
@@ -73,7 +80,7 @@ export namespace unstable {
constructor(message="The event was too old to be handled by the bridge") {
super(message);
this.name = "EventTooOldError";
- this.reason = "m.event_too_old";
+ this.internalReason = "m.event_too_old";
}
}
@@ -84,7 +91,7 @@ export namespace unstable {
constructor(message="The bridge experienced an internal error") {
super(message);
this.name = "EventTooOldError";
- this.reason = "m.internal_error";
+ this.internalReason = "m.internal_error";
}
}
@@ -95,7 +102,7 @@ export namespace unstable {
constructor(message="The foreign network experienced an error") {
super(message);
this.name = "ForeignNetworkError";
- this.reason = "m.foreign_network_error";
+ this.internalReason = "m.foreign_network_error";
}
}
@@ -106,7 +113,7 @@ export namespace unstable {
constructor(message="The event is not known to the bridge") {
super(message);
this.name = "EventUnknownError";
- this.reason = "m.event_unknown";
+ this.internalReason = "m.event_unknown";
}
}
}
diff --git a/src/index.ts b/src/index.ts
index 4a289a74..b11fa6d8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,8 +26,6 @@ export * from "./components/state-lookup";
// Config and CLI
export * from "./components/cli";
export * from "./components/config-validator";
-/* eslint-disable @typescript-eslint/no-var-requires */
-module.exports.ConfigValidator = require("./components/config-validator");
// Store
export * from "./components/bridge-store";
@@ -41,32 +39,19 @@ export * from "./models/rooms/remote";
export * from "./models/users/matrix";
export * from "./models/users/remote";
export * from "./models/events/event";
-
-module.exports.Bridge = require("./bridge");
+export * from "./bridge";
export * from "./components/bridge-context";
export * from "matrix-appservice";
+export * from "./components/prometheusmetrics";
+export * from "./components/membership-cache";
+export * as Logging from "./components/logging";
+export { unstable } from "./errors";
+/* eslint-disable @typescript-eslint/no-var-requires */
const jsSdk = require("matrix-js-sdk");
export const ContentRepo = {
getHttpUriForMxc: jsSdk.getHttpUriForMxc,
getIdenticonUri: jsSdk.getIdenticonUri,
}
-
-export * from "./components/prometheusmetrics";
-module.exports.PrometheusMetrics.AgeCounters = require("./components/agecounters").AgeCounters;
-
-// Caches
-export * from "./components/membership-cache";
-
-// Logging
-export * as Logging from "./components/logging";
-
-// Consts for RoomLinkValidator
-module.exports.RoomLinkValidatorStatus = require(
- "./components/room-link-validator"
-).validationStatuses;
-
-// Errors
-export { unstable } from "./errors";
\ No newline at end of file
diff --git a/src/thirdparty.ts b/src/thirdparty.ts
new file mode 100644
index 00000000..75e668cf
--- /dev/null
+++ b/src/thirdparty.ts
@@ -0,0 +1,31 @@
+export interface ProtocolInstance {
+ desc: string;
+ icon?: string;
+ fields?: Record;
+ network_id: string;
+}
+
+export interface ThirdpartyProtocolResponse {
+ user_fields: string[];
+ location_fields: string[];
+ icon: string;
+ field_types: {
+ [field_type: string]: {
+ regexp: string;
+ placeholder: string;
+ }
+ };
+ instances: ProtocolInstance[];
+}
+
+export interface ThirdpartyLocationResponse {
+ alias: string;
+ protocol: string;
+ fields: Record;
+}
+
+export interface ThirdpartyUserResponse {
+ userid: string;
+ protocol: string;
+ fields: Record;
+}
diff --git a/src/utils/promiseutil.ts b/src/utils/promiseutil.ts
index 11ea2d41..506d98a5 100644
--- a/src/utils/promiseutil.ts
+++ b/src/utils/promiseutil.ts
@@ -14,18 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import Bluebird from "bluebird";
-
export interface Defer {
resolve: (value?: T) => void;
reject: (err?: unknown) => void;
- promise: Bluebird;
+ promise: Promise;
}
export function defer(): Defer {
let resolve!: (value?: T) => void;
let reject!: (err?: unknown) => void;
- const promise = new Bluebird((res, rej) => {
+ const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});