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; });