Repository: turt2live/matrix-bot-sdk Branch: main Commit: abfc53c5a040 Files: 184 Total size: 1.2 MB Directory structure: gitextract_mgmy1jcz/ ├── .eslintrc.js ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yaml │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── SECURITY.md │ └── workflows/ │ ├── docs.yml │ └── static_analysis.yml ├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ ├── index.md │ └── tutorials/ │ ├── appservice.md │ ├── bot-to-appservice.md │ ├── bot.md │ ├── encryption-appservices.md │ ├── encryption-bots.md │ ├── encryption.md │ ├── index.json │ └── room-upgrades.md ├── eslint.config.js ├── examples/ │ ├── appservice.ts │ ├── bot.ts │ ├── encryption_appservice.ts │ ├── encryption_bot.ts │ └── login_register.ts ├── jsdoc.json ├── package.json ├── src/ │ ├── AdminApis.ts │ ├── DMs.ts │ ├── IFilter.ts │ ├── MatrixAuth.ts │ ├── MatrixClient.ts │ ├── PantalaimonClient.ts │ ├── SynapseAdminApis.ts │ ├── SynchronousMatrixClient.ts │ ├── UnstableApis.ts │ ├── appservice/ │ │ ├── Appservice.ts │ │ ├── Intent.ts │ │ ├── MatrixBridge.ts │ │ ├── UnstableAppserviceApis.ts │ │ └── http_responses.ts │ ├── b64.ts │ ├── e2ee/ │ │ ├── CryptoClient.ts │ │ ├── ICryptoRoomInformation.ts │ │ ├── RoomTracker.ts │ │ ├── RustEngine.ts │ │ └── decorators.ts │ ├── helpers/ │ │ ├── MatrixEntity.ts │ │ ├── MatrixGlob.ts │ │ ├── MentionPill.ts │ │ ├── Permalinks.ts │ │ ├── ProfileCache.ts │ │ ├── RichReply.ts │ │ └── UnpaddedBase64.ts │ ├── http.ts │ ├── identity/ │ │ └── IdentityClient.ts │ ├── index.ts │ ├── logging/ │ │ ├── ConsoleLogger.ts │ │ ├── ILogger.ts │ │ ├── LogService.ts │ │ └── RichConsoleLogger.ts │ ├── metrics/ │ │ ├── IMetricListener.ts │ │ ├── Metrics.ts │ │ ├── contexts.ts │ │ ├── decorators.ts │ │ └── names.ts │ ├── mixins/ │ │ ├── AutojoinRoomsMixin.ts │ │ └── AutojoinUpgradedRoomsMixin.ts │ ├── models/ │ │ ├── Account.ts │ │ ├── CreateRoom.ts │ │ ├── Crypto.ts │ │ ├── EventContext.ts │ │ ├── IdentityServerModels.ts │ │ ├── MSC2176.ts │ │ ├── MatrixError.ts │ │ ├── MatrixProfile.ts │ │ ├── OpenIDConnect.ts │ │ ├── Policies.ts │ │ ├── PowerLevelAction.ts │ │ ├── PowerLevelBounds.ts │ │ ├── Presence.ts │ │ ├── ServerVersions.ts │ │ ├── Spaces.ts │ │ ├── Threepid.ts │ │ ├── events/ │ │ │ ├── AliasesEvent.ts │ │ │ ├── CanonicalAliasEvent.ts │ │ │ ├── CreateEvent.ts │ │ │ ├── EncryptedRoomEvent.ts │ │ │ ├── EncryptionEvent.ts │ │ │ ├── Event.ts │ │ │ ├── EventKind.ts │ │ │ ├── InvalidEventError.ts │ │ │ ├── JoinRulesEvent.ts │ │ │ ├── MembershipEvent.ts │ │ │ ├── MessageEvent.ts │ │ │ ├── PinnedEventsEvent.ts │ │ │ ├── PowerLevelsEvent.ts │ │ │ ├── PresenceEvent.ts │ │ │ ├── RedactionEvent.ts │ │ │ ├── RoomAvatarEvent.ts │ │ │ ├── RoomEvent.ts │ │ │ ├── RoomNameEvent.ts │ │ │ ├── RoomTopicEvent.ts │ │ │ ├── SpaceChildEvent.ts │ │ │ ├── _MissingEvents.md │ │ │ └── converter.ts │ │ └── unstable/ │ │ └── MediaInfo.ts │ ├── preprocessors/ │ │ ├── IPreprocessor.ts │ │ └── RichRepliesPreprocessor.ts │ ├── request.ts │ ├── simple-validation.ts │ ├── storage/ │ │ ├── IAppserviceStorageProvider.ts │ │ ├── ICryptoStorageProvider.ts │ │ ├── IStorageProvider.ts │ │ ├── MemoryStorageProvider.ts │ │ ├── RustSdkCryptoStorageProvider.ts │ │ ├── SimpleFsStorageProvider.ts │ │ └── SimplePostgresStorageProvider.ts │ └── strategies/ │ ├── AppserviceJoinRoomStrategy.ts │ └── JoinRoomStrategy.ts ├── test/ │ ├── AdminApisTest.ts │ ├── DMsTest.ts │ ├── IdentityClientTest.ts │ ├── MatrixAuthTest.ts │ ├── MatrixClientTest.ts │ ├── SynapseAdminApisTest.ts │ ├── SynchronousMatrixClientTest.ts │ ├── TestUtils.ts │ ├── UnstableApisTest.ts │ ├── appservice/ │ │ ├── AppserviceTest.ts │ │ ├── IntentTest.ts │ │ ├── MatrixBridgeTest.ts │ │ └── UnstableAppserviceApisTest.ts │ ├── b64Test.ts │ ├── encryption/ │ │ ├── CryptoClientTest.ts │ │ ├── RoomTrackerTest.ts │ │ └── decoratorsTest.ts │ ├── helpers/ │ │ ├── MatrixEntityTest.ts │ │ ├── MatrixGlobTest.ts │ │ ├── MentionPillTest.ts │ │ ├── PermalinksTest.ts │ │ ├── ProfileCacheTest.ts │ │ ├── RichReplyTest.ts │ │ └── UnpaddedBase64Test.ts │ ├── logging/ │ │ └── LogServiceTest.ts │ ├── metrics/ │ │ ├── MetricsTest.ts │ │ └── decoratorsTest.ts │ ├── mixins/ │ │ ├── AutojoinRoomsMixinTest.ts │ │ └── AutojoinUpgradedRoomsMixinTest.ts │ ├── models/ │ │ ├── MatrixProfileTest.ts │ │ ├── PresenceTest.ts │ │ ├── SpacesTest.ts │ │ └── events/ │ │ ├── AliasesEventTest.ts │ │ ├── CanonicalAliasEventTest.ts │ │ ├── CreateEventTest.ts │ │ ├── EncryptedRoomEventTest.ts │ │ ├── EncryptionEventTest.ts │ │ ├── EventTest.ts │ │ ├── JoinRulesEventTest.ts │ │ ├── MembershipEventTest.ts │ │ ├── MessageEventTest.ts │ │ ├── PinnedEventsEventTest.ts │ │ ├── PowerLevelsEventTest.ts │ │ ├── RedactionEventTest.ts │ │ ├── RoomAvatarEventTest.ts │ │ ├── RoomNameEventTest.ts │ │ ├── RoomTopicEventTest.ts │ │ ├── SpaceChildEventTest.ts │ │ └── converterTest.ts │ ├── preprocessors/ │ │ └── RichRepliesPreprocessorTest.ts │ ├── requestTest.ts │ ├── simple-validationTest.ts │ ├── storage/ │ │ ├── MemoryStorageProviderTest.ts │ │ ├── SimpleFsStorageProviderTest.ts │ │ └── SimplePostgresStorageProviderTest.ts │ └── strategies/ │ ├── AppserviceJoinRoomStrategyTest.ts │ └── JoinRoomStrategyTest.ts ├── tsconfig-examples.json ├── tsconfig-release.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { plugins: [ "matrix-org", ], extends: [ "plugin:matrix-org/babel", ], env: { browser: true, node: true, }, rules: { "no-var": ["warn"], "prefer-rest-params": ["warn"], "prefer-spread": ["warn"], "one-var": ["warn"], "padded-blocks": ["warn"], "no-extend-native": ["warn"], "camelcase": ["warn"], "no-multi-spaces": ["error", { "ignoreEOLComments": true }], "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", "asyncArrow": "always", }], "arrow-parens": "off", "prefer-promise-reject-errors": "off", "quotes": "off", "indent": "off", "no-constant-condition": "off", "no-async-promise-executor": "off", // We use a `LogService` intermediary module "no-console": "error", }, overrides: [{ files: [ "**/*.ts", ], extends: [ "plugin:matrix-org/typescript", ], rules: { // TypeScript has its own version of this "@babel/no-invalid-this": "off", // We're okay being explicit at the moment "@typescript-eslint/no-empty-interface": "off", // We disable this while we're transitioning "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", // We're okay with assertion errors when we ask for them "@typescript-eslint/no-non-null-assertion": "off", "quotes": "off", // We use a `logger` intermediary module "no-console": "error", "max-len": ["error", { "code": 180 }], "no-extra-boolean-cast": "off", }, }], }; ================================================ FILE: .github/CODEOWNERS ================================================ * turt2live ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: 'bug' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Log snippet** ``` If the bug can be seen in your logs, please paste them here (between the triple backticks). ``` **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yaml ================================================ blank_issues_enabled: true contact_links: - name: Community Support url: "https://matrix.to/#/#bot-sdk:t2bot.io" about: General support questions can be asked here. - name: Security Policy url: https://www.t2host.io/docs/legal/security-disclosure-policy-v1/ about: Learn more about our security disclosure policy. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: 'enhancement' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the concern is. Ex. The [...] function doesn't work **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Checklist * [ ] Tests written for all new code * [ ] Linter has been satisfied * [ ] Sign-off given on the changes (see CONTRIBUTING.md) ================================================ FILE: .github/SECURITY.md ================================================ # Reporting a vulnerability **If you've found a security vulnerability, please report it by DMing [@travis:t2l.io](https://matrix.to/#/@travis:t2l.io) on Matrix** This project is managed under t2bot.io's Security Disclosure Policy, which upstreams to the Matrix.org Foundation as needed. For more information on t2bot.io's SDP, visit https://www.t2host.io/docs/legal/security-disclosure-policy-v1/ ================================================ FILE: .github/workflows/docs.yml ================================================ name: Docs on: push: branches: - main jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '24' - run: yarn install - run: yarn docs - name: Build and deploy docs uses: JamesIves/github-pages-deploy-action@v4 with: branch: gh-pages folder: .jsdoc/matrix-bot-sdk/develop ================================================ FILE: .github/workflows/static_analysis.yml ================================================ name: Static Analysis on: push: branches: - main pull_request: jobs: # Global # ================================================ eslint: name: 'ESLint' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '24' # Target desired node version - run: yarn install - run: yarn lint # Node-specific # ================================================ build: strategy: matrix: node: [ 22, 24 ] name: 'Build Node ${{ matrix.node }}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - run: yarn install - run: yarn build - run: yarn build:examples build-docs: strategy: matrix: node: [ 22, 24 ] name: 'Build Docs Node ${{ matrix.node }}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - run: yarn install - run: yarn docs tests: strategy: matrix: node: [ 22, 24 ] name: 'Tests Node ${{ matrix.node }}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - run: yarn install - uses: nick-invision/retry@v2 with: max_attempts: 3 timeout_minutes: 15 command: yarn test ================================================ FILE: .gitignore ================================================ .idea/ lib/ examples/storage/ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # jsdoc files .jsdoc ================================================ FILE: .npmignore ================================================ src/ .travis.yml .idea/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to matrix-bot-sdk Woo! Good to see you're interested in contributing a change to the bot-sdk! Everyone is welcome to contribute, however there are some requirements in order to do so. Namely, all contributors must be willing to license their changes under the same license as the project itself. We follow a simple "inbound=outbound" model for contributions: the act of submitting an "inbound" contribution means that the contributor agrees to license the code under the same terms as the project's overall "outbound" license - in this case, MIT (per [LICENSE](./LICENSE)). ## How to contribute The easiest way to contribute is by forking the project and opening a PR. We highly recommend using a branch for your changes to make it easier to maintain your PRs and open several (if needed). Unfortunately at this time it might take a little while to have your PR reviewed: one day it will be faster, but in the meantime please be patient. ## Sign off In order to have a concrete record that your contribution is intentional and you agree to license it under the same terms as the project's license, we've adopted the same lightweight approach that the Linux Kernel (https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker (https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other projects use: the DCO (Developer Certificate of Origin: http://developercertificate.org/). This is a simple declaration that you wrote the contribution or otherwise have the right to contribute it to matrix-bot-sdk: ``` Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 660 York Street, Suite 102, San Francisco, CA 94110 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` If you agree to this for your contribution, then all that's needed is to include the line in your commit or pull request comment: ``` Signed-off-by: Your Name ``` We accept contributions under a legally identifiable name, such as your name on government documentation or common-law names (names claimed by legitimate usage or repute). Unfortunately, we cannot accept anonymous contributions at this time. Git allows you to add this signoff automatically when using the `-s` flag to `git commit`, which uses the name and email set in your `user.name` and `user.email` git configs. If you forgot to sign off your commits before making your pull request and are on Git 2.17+ you can mass signoff using rebase: ``` git rebase --signoff origin/develop ``` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 - 2023 Travis Ralston Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # matrix-bot-sdk [![npm version](https://badge.fury.io/js/matrix-bot-sdk.svg)](https://www.npmjs.com/package/matrix-bot-sdk) TypeScript/JavaScript SDK for Matrix bots. For help and support, visit [#matrix-bot-sdk:t2bot.io](https://matrix.to/#/#matrix-bot-sdk:t2bot.io) # Documentation Documentation for the project is available [here](https://turt2live.github.io/matrix-bot-sdk/index.html). # Matrix version support The Matrix protocol is [versioned](https://spec.matrix.org/latest/#specification-versions) to ensure endpoints and functionality can safely rotate in and out of the ecosystem. The bot-sdk will assume it is connected to a homeserver with support for at least one of the last 2 versions, at the time of the bot-sdk's release. This means that if you connect the bot-sdk to a homeserver which is 3 or more Matrix versions out of date, things might not work for you. It is recommended to update the bot-sdk as frequently as spec releases themselves (or faster) to avoid this situation, and watch the repo for updates in the event a release is delayed. **Note**: Currently the bot-sdk does not throw an error if the server appears to be incompatible, however this might change in the future. ================================================ FILE: docs/index.md ================================================ [![npm version](https://badge.fury.io/js/matrix-bot-sdk.svg)](https://www.npmjs.com/package/matrix-bot-sdk) TypeScript/JavaScript SDK for Matrix bots. For help and support, visit [#matrix-bot-sdk:t2bot.io](https://matrix.to/#/#matrix-bot-sdk:t2bot.io) ## Templates and guides * [Bot documentation](https://turt2live.github.io/matrix-bot-sdk/tutorial-bot.html) * [Appservice/bridge documentation](https://turt2live.github.io/matrix-bot-sdk/tutorial-appservice.html) * [matrix.org's guide on the basic functions of the bot](https://matrix.org/docs/guides/usage-of-matrix-bot-sdk) * [GitHub bot template repository](https://github.com/turt2live/matrix-bot-sdk-bot-template) ## Installing This package can be found on [npm](https://www.npmjs.com/package/matrix-bot-sdk): ``` npm install matrix-bot-sdk ``` ================================================ FILE: docs/tutorials/appservice.md ================================================ Application services are essentially superpowered bots with a much more capable connection to the homeserver. While bots can operate on nearly any homeserver, appservices need to be specifically installed and configured by the server admins. Because of the direct connection nature, and the ability to reserve a whole namespace of user IDs, appservices typically take the shape of bridges. They also typically take the shape of single-user bots which outgrew the performance of calling `/sync` in a loop. Appservices are added to homeservers using a registration file. Typically, these are YAML files which get added/listed to the server config somewhere. Check your homeserver software's documentation for how to install an appservice. The bot-sdk does not automatically generate a registration file, but it is trivial to generate one by hand. Implementations typically request that the server admin also supply an exact copy of the registration file so it can be handed off to the bot-sdk to handle. Advanced uses (ie: multiple namespaces for a single appservice) might require translating the registration file into something the bot-sdk is willing to accept, however most cases will be perfectly fine to just read it in directly. An example registration file is: ```yaml # A general purpose identifier for the appservice. Typically, this is just a lowercase version of the application # name. It should be unique among all other appservices installed on the homeserver. id: mybridge # These are the authentication secrets used to communicate between the homeserver and appservice. They should # be secret, sufficiently complex, and different from each other and all other appservices installed on the # homeserver. as_token: hs_token: # These are the namespaces that the appservice would like to reserve or use. Typically, bridges will want to # reserve an alias and user namespace. namespaces: aliases: - exclusive: true # It's good practice to limit the regex to just bridge users on the current homeserver to avoid confusing # your bridge with other people who might be using it. regex: "#bridge_.+:example.org" users: - exclusive: true regex: "@bridge_.+:example.org" rooms: [] # not commonly used, but required to be set rate_limited: false # typical bridges don't want to be rate limited # This is the localpart of the primary user for the appservice. For bridges, this is typically known as the # "bridge bot" user. sender_localpart: "bridge_bot" # This is where the homeserver can reach your appservice at. The bot-sdk will automatically expose a webserver # at the configured port to handle this traffic and turn it into useful events. url: "http://localhost:9000" # If you need ephemeral events (for crypto or other reasons), set this to true. Defaults to false to avoid flooding # the appservice wtih traffic. de.sorunome.msc2409.push_ephemeral: true ``` ## Creating the appservice instance The {@link Appservice} class wants a whole bunch of options, though the details are not much different from a regular bot. Namely, it wants a storage mechanism, config options for the webserver, and an appservice registration to use as reference. ```typescript const registration: {@link IAppserviceRegistration} = {/* ... typically read from the YAML file ... */ }; const options: {@link IAppserviceOptions} = { // Webserver options port: 9000, bindAddress: "0.0.0.0", // Where the appservice can reach the homeserver at. This should be the same URL configured for clients and bots. homeserverUrl: "https://example.org", // The domain name of the homeserver. This is the part that is included in user IDs. homeserverName: "example.org", registration: registration, storage: new SimpleFsStorageProvider("./path/to/appservice.json"), // or any other {@link IAppserviceStorageProvider} joinStrategy: new SimpleRetryJoinStrategy(), // just to ensure reliable joins }; const appservice = new Appservice(options); // Attach listeners here appservice.on("room.message", (roomId: string, event: any) => { if (!event['content']?.['msgtype']) return; // handle message }); // Typically appservices will want to autojoin all rooms AutojoinRoomsMixin.setupOnAppservice(appservice); // Actually start the appservice appservice.begin().then(() => console.log("Appservice started")); ``` The `appservice` instance will emit all the same stuff as a regular bot. Check out the bot tutorial for more information: {@tutorial bot}. ## Intents Intents are how the bot-sdk deals with the namespace of users declared in the registration. The bridge bot user is also an intent, though with a special accessor. To get the bridge bot intent, use `appservice.botIntent`. For all other intents, `appservice.getIntentForSuffix("suffix")` is typically easiest. The {@link Intent} class has all sorts of built-in functions, though if you need to do something more complex then you may need to handle the intent differently: ```typescript const intent = appservice.getIntentForSuffix("your_suffix"); // typically a suffix is an identifier from a third party platform await intent.ensureRegistered(); // can be called multiple times safely await intent.underlyingClient.setDisplayName("Name"); // or whatever function you'd like to call ``` Typically the bridge bot intent is used for anything which doesn't need to be impersonated by a specific user ID, such as querying room state or inviting users. The bridge client is exposed as `appservice.botClient` for easy access. ================================================ FILE: docs/tutorials/bot-to-appservice.md ================================================ Once a bot has outgrown the performance of a `/sync` loop, it's typically time to convert it to an appservice. This involves changing how the application receives events from the server, and possibly even migrating datastores if using custom storage providers. For the purposes of this tutorial, we'll assume the bot has been running as `@bot:example.org` with an access token. First, you'll need to create a registration file. Check out the appservices tutorial for more information on what this is: {@tutorial appservice}. ```yaml id: examplebot as_token: hs_token: url: "http://localhost:9000" # where your bot can be reached at the built-in webserver for the bot-sdk sender_localpart: "bot" # We don't need any namespaces, but they need to be declared namespaces: users: [] aliases: [] rooms: [] rate_limited: false de.sorunome.msc2409.push_ephemeral: true # default false. Keep false if not using typing notifications, encryption, etc. ``` That registration will need to be installed on the homeserver. Consult your homeserver documentation for more information. Next, you'll need to incorporate that into the bot-sdk's interface. Because the bot-sdk is more geared to bridges, we'll have to lie to it a bit to ensure it stays happy. ```typescript const registration: IAppserviceRegistration = { id: "examplebot", as_token: "", hs_token: "", url: "http://localhost:9000", // not used by bot-sdk, but good to define for documentation purposes sender_localpart: "bot", namespaces: { users: [{exclusive: true, regex: "@bot.+"}], // we won't be using anything in the namespace, but need to define it aliases: [], rooms: [], }, // For documentation purposes: rate_limited: false, "de.sorunome.msc2409.push_ephemeral": true, }; ``` Then, you'll need to define the appservice options. This will be things such as where the internal webserver will listen at and where its data is stored. If you used a storage provider built into the bot-sdk, it can be reused here. ```typescript const options: IAppserviceOptions = { // Webserver options port: 9000, bindAddress: "0.0.0.0", // This should be the same URL used by the bot. homeserverUrl: "https://example.org", // The domain name of the homeserver. This is the part that is included in user IDs. homeserverName: "example.org", registration: registration, storage: new SimpleFsStorageProvider("./path/to/bot.json"), joinStrategy: new SimpleRetryJoinStrategy(), // just to ensure reliable joins }; ``` Now, your listeners from your bot can be attached to the `appservice` instance instead: ```typescript // Old code: // bot.on("room.message", (roomId: string, event: any) => { // if (!event['content']?.['msgtype']) return; // // // handle message // }); // --------------- // New code: appservice.on("room.message", (roomId: string, event: any) => { if (!event['content']?.['msgtype']) return; // handle message }); ``` Finally, start the appservice and give it a go: `appservice.begin().then(() => console.log("Appservice started"));` If you need to access a {@link MatrixClient} instance for calling functions, use `appservice.botClient`. Note that the client instance will not emit events because it will not be syncing/will not be started. It should not have `start()` called on it as this can cause data loss/duplication. ================================================ FILE: docs/tutorials/bot.md ================================================ Bots are typically simple creatures which act on commands and provide utility to rooms. They work very similar to how normal Matrix clients work, with the added complexity of needing to be run on a server somewhere. Unlike appservices (bridges), bots do not need to be added by a server admin and can be attached to any regular account. For a guide starting from scratch, check out the [matrix.org guide](https://matrix.org/docs/guides/usage-of-matrix-bot-sdk). ## Creating the bot account The bot-sdk can be used to script a simple registration or login script, depending on whether or not an account has been made prior to deploying the bot. If you already have an access token, skip this section. **Registration**: ```typescript import { MatrixAuth } from "matrix-bot-sdk"; // This will be the URL where clients can reach your homeserver. Note that this might be different // from where the web/chat interface is hosted. The server must support password registration without // captcha or terms of service (public servers typically won't work). const homeserverUrl = "https://example.org"; const auth = new MatrixAuth(homeserverUrl); const client = await auth.passwordRegister("username", "password"); console.log("Copy this access token to your bot's config: ", client.accessToken); ``` **Login** (preferred): ```typescript import { MatrixAuth } from "matrix-bot-sdk"; // This will be the URL where clients can reach your homeserver. Note that this might be different // from where the web/chat interface is hosted. The server must support password registration without // captcha or terms of service (public servers typically won't work). const homeserverUrl = "https://example.org"; const auth = new MatrixAuth(homeserverUrl); const client = await auth.passwordLogin("username", "password"); console.log("Copy this access token to your bot's config: ", client.accessToken); ``` In either case, the access token printed at the end will need copying to your bot's config. ## Quickstart bot As an example, a bot which replies to `!hello` commands would be: ```typescript import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin, } from "matrix-bot-sdk"; // This will be the URL where clients can reach your homeserver. Note that this might be different // from where the web/chat interface is hosted. The server must support password registration without // captcha or terms of service (public servers typically won't work). const homeserverUrl = "https://example.org"; // Use the access token you got from login or registration above. const accessToken = "ACQUIRED_FROM_ABOVE"; // In order to make sure the bot doesn't lose its state between restarts, we'll give it a place to cache // any information it needs to. You can implement your own storage provider if you like, but a JSON file // will work fine for this example. const storage = new SimpleFsStorageProvider("hello-bot.json"); // Finally, let's create the client and set it to autojoin rooms. Autojoining is typical of bots to ensure // they can be easily added to any room. const client = new MatrixClient(homeserverUrl, accessToken, storage); AutojoinRoomsMixin.setupOnClient(client); // Before we start the bot, register our command handler client.on("room.message", handleCommand); // Now that everything is set up, start the bot. This will start the sync loop and run until killed. client.start().then(() => console.log("Bot started!")); // This is the command handler we registered a few lines up async function handleCommand(roomId: string, event: any) { // Don't handle unhelpful events (ones that aren't text messages, are redacted, or sent by us) if (event['content']?.['msgtype'] !== 'm.text') return; if (event['sender'] === await client.getUserId()) return; // Check to ensure that the `!hello` command is being run const body = event['content']['body']; if (!body?.startsWith("!hello")) return; // Now that we've passed all the checks, we can actually act upon the command await client.replyNotice(roomId, event, "Hello world!"); } ``` ## Watching for events A `MatrixClient` instance will call listeners for various different things that might happen after the bot has started. ### [Room messages](https://spec.matrix.org/latest/client-server-api/#instant-messaging) & [events](https://spec.matrix.org/latest/client-server-api/#events) ```typescript client.on("room.message", (roomId: string, event: any) => { // `event['type']` will always be an `m.room.message` and can be processed as such // be sure to check if the event is redacted/invalid though: if (!event['content']?.['msgtype']) return; }); ``` ```typescript client.on("room.event", (roomId: string, event: any) => { // Check `event['type']` to see if it is an event you're interested in if (event['type'] !== 'org.example.custom') return; // Note that state events can also be sent down this listener too if (event['state_key'] !== undefined) return; // state event }); ``` ### [Account data](https://spec.matrix.org/latest/client-server-api/#client-config) ```typescript client.on("account_data", (event: any) => { // Handle the account data update }); ``` ```typescript client.on("room.account_data", (roomId: string, event: any) => { // Handle the room account data update }); ``` ### Room membership ```typescript client.on("room.join", (roomId: string, event: any) => { // The client has joined `roomId` }); ``` ```typescript client.on("room.leave", (roomId: string, event: any) => { // The client has left `roomId` (either voluntarily, kicked, or banned) }); ``` ```typescript client.on("room.join", (roomId: string, event: any) => { // The client has been invited to `roomId` }); ``` ================================================ FILE: docs/tutorials/encryption-appservices.md ================================================ Encryption for appservices is just about as easy as bots, though involves using a storage mechanism which is capable of handling the higher traffic. Eventually the SDK will support custom stores, however for now the crypto store must be a {@link RustSdkAppserviceCryptoStorageProvider}. ```typescript const storage = new SimpleFsStorageProvider("./path/to/appservice.json"); // or any other {@link IStorageProvider} const cryptoStorage = new RustSdkAppserviceCryptoStorageProvider("./path/to/directory"); // ⚠⚠ Be sure to back up both `./path/to/appservice.json` and `./path/to/directory` when using this setup const registration: IAppserviceRegistration = { /* ... */ "de.sorunome.msc2409.push_ephemeral": true, }; const options: IAppserviceOptions = { /* ... */ storage: storage, cryptoStorage: cryptoStorage, intentOptions: { // Enable encryption on all appservice users, including the `sender_localpart` user encryption: true, }, } const appservice = new Appservice(options); ``` ## Advanced usage To monitor the encryption/decryption process, add the following listeners: ```typescript appservice.on("room.encrypted_event", (roomId: string, event: any) => { // handle `m.room.encrypted` event that was received from the server }); ``` ```typescript appservice.on("room.decrypted_event", (roomId: string, event: any) => { // handle a decrypted `m.room.encrypted` event (`event` will be representative of the cleartext event). // this is effectively the same as `on('room.event', ...)` though at a different point in the lifecycle. }); ``` ```typescript appservice.on("room.failed_decryption", (roomId: string, event: any, error: Error) => { // handle `m.room.encrypted` event that could not be decrypted }); ``` To control when encryption is set up for {@link Intent}s, set `intentOptions.encryption = false` in the appservice options and call `await intent.enableEncryption()` before encryption will be needed. It is safe to call multiple times. ================================================ FILE: docs/tutorials/encryption-bots.md ================================================ Setting up encryption for a bot is easy: simply provide a crypto storage provider in addition to your other storage providers and it'll start working behind the scenes. ```typescript const storageProvider = new SimpleFsStorageProvider("./path/to/bot.json"); // or any other {@link IStorageProvider} const cryptoProvider = new RustSdkCryptoStorageProvider("./path/to/directory"); // ⚠⚠ Be sure to back up both `./path/to/bot.json` and `./path/to/directory` when using this setup const homeserverUrl = "https://example.org"; // where the bot can reach the homeserver at const accessToken = "..."; // acquired from login or registration. // ℹ The access token for the bot should remain consistent. The crypto storage in particular will assume that the // device ID (and thus access token) does not change between restarts. If the access token becomes invalid, or the // crypto storage is lost, a new access token and new crypto storage will need to be created. const client = new MatrixClient(homeserverUrl, accessToken, storageProvider, cryptoProvider); // set up your listeners here client.on("room.message", (roomId: string, event: any) => { if (!event['content']?.['msgtype']) return; // handle message here. It'll be decrypted already. }); // This will set up crypto if needed and prepare the client for automatically decrypting and encrypting messages. Simply // use the client like you would without encryption and it should just work. client.start().then(() => console.log("Bot started!")); ``` ## Advanced usage To monitor the encryption/decryption process, add the following listeners: ```typescript client.on("room.encrypted_event", (roomId: string, event: any) => { // handle `m.room.encrypted` event that was received from the server }); ``` ```typescript client.on("room.decrypted_event", (roomId: string, event: any) => { // handle a decrypted `m.room.encrypted` event (`event` will be representative of the cleartext event). // this is effectively the same as `on('room.event', ...)` though at a different point in the lifecycle. }); ``` ```typescript client.on("room.failed_decryption", (roomId: string, event: any, error: Error) => { // handle `m.room.encrypted` event that could not be decrypted }); ``` ================================================ FILE: docs/tutorials/encryption.md ================================================ Matrix supports end-to-end encryption between users in encrypted rooms. Not all rooms are encrypted, and most bots and bridges do not support encryption out of the gate. With the bot-sdk, encryption (or crypto) needs to be turned on deliberately in the code. The following guides go into detail on how to enable encryption for different use cases: * {@tutorial encryption-bots} * {@tutorial encryption-appservices} ## General principles For both bots and appservices, an {@link ICryptoStorageProvider} will be needed to actually enable encryption. Eventually this will be able to be your own implementation, but for now must be a {@link RustSdkCryptoStorageProvider} or derivative. ================================================ FILE: docs/tutorials/index.json ================================================ { "bot": { "title": "Bot usage", "order": 1 }, "appservice": { "title": "Appservice (bridge) usage", "order": 2, "children": { "bot-to-appservice": { "title": "Converting a bot to an appservice", "order": 1 } } }, "room-upgrades": { "title": "Room upgrades", "order": 4 }, "encryption": { "title": "Encryption", "order": 3, "children": { "encryption-bots": { "title": "Encryption for bots", "order": 1 }, "encryption-appservices": { "title": "Encryption for appservices", "order": 2 } } } } ================================================ FILE: docs/tutorials/room-upgrades.md ================================================ When a room is upgraded, bots and bridges might have to relocate data to the new room. This SDK can handle the easier part of ensuring the bot/bridge is in the new room, and emits events to make the remainder of the process a little easier. An upgrade happens in two phases: a `room.archived` phase where the old room is flagged as being replaced by another room and a `room.upgraded` phase once the bot/bridge is aware of the new room. Bots and appservices can be told to automatically try and join the new room by attaching a `AutojoinUpgradedRoomsMixin` to the client/appservice, much like the `AutojoinRoomsMixin`. Bots and appservices should listen for `room.upgraded` to perform a data transfer as this is when there is referential integrity between the two rooms. Prior to an upgrade, there is no guarantee that the replacement room advertised is actually valid. To get the full chain of rooms, use `getRoomUpgradeHistory(roomId)` on a `MatrixClient` (ie: the `botIntent.underlyingClient` or your own). ================================================ FILE: eslint.config.js ================================================ const { defineConfig, } = require("eslint/config"); const matrixOrg = require("eslint-plugin-matrix-org"); const globals = require("globals"); const js = require("@eslint/js"); const { FlatCompat, } = require("@eslint/eslintrc"); const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, allConfig: js.configs.all }); module.exports = defineConfig([{ plugins: { "matrix-org": matrixOrg, }, extends: compat.extends("plugin:matrix-org/babel"), languageOptions: { globals: { ...globals.browser, ...globals.node, }, }, rules: { "valid-jsdoc": ["off"], "require-jsdoc": ["off"], "unicorn/no-instanceof-array": "off", "no-var": ["warn"], "prefer-rest-params": ["warn"], "prefer-spread": ["warn"], "one-var": ["warn"], "padded-blocks": ["warn"], "no-extend-native": ["warn"], "camelcase": ["warn"], "no-multi-spaces": ["error", { "ignoreEOLComments": true, }], "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", "asyncArrow": "always", }], "arrow-parens": "off", "prefer-promise-reject-errors": "off", "quotes": "off", "indent": "off", "no-constant-condition": "off", "no-async-promise-executor": "off", "no-console": "error", }, }, { files: ["**/*.ts"], extends: compat.extends("plugin:matrix-org/typescript"), rules: { "valid-jsdoc": ["off"], "require-jsdoc": ["off"], "unicorn/no-instanceof-array": "off", "@babel/no-invalid-this": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/consistent-type-exports": "off", "@typescript-eslint/consistent-type-imports": "off", "@typescript-eslint/explicit-member-accessibility": "off", "@typescript-eslint/no-wrapper-object-types": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-empty-object-type": "off", "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-base-to-string": "off", "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-non-null-assertion": "off", "@stylistic/member-delimiter-style": "off", "quotes": "off", "no-console": "error", "max-len": ["error", { "code": 180, }], "no-extra-boolean-cast": "off", }, }]); ================================================ FILE: examples/appservice.ts ================================================ import { Appservice, AutojoinRoomsMixin, IAppserviceOptions, IAppserviceRegistration, LogService, MemoryStorageProvider, SimpleRetryJoinStrategy, } from "../src"; const registration: IAppserviceRegistration = { as_token: "change_me", hs_token: "change_me", sender_localpart: "_example_bot", namespaces: { users: [{ regex: "@_example_.*:localhost", exclusive: true, }], rooms: [], aliases: [], }, }; LogService.info("index", "Setting up appservice with in-memory storage"); const storage = new MemoryStorageProvider(); const options: IAppserviceOptions = { bindAddress: "0.0.0.0", port: 9000, homeserverName: "localhost", homeserverUrl: "http://localhost:8008", storage: storage, registration: registration, joinStrategy: new SimpleRetryJoinStrategy(), }; const appservice = new Appservice(options); AutojoinRoomsMixin.setupOnAppservice(appservice); appservice.on("room.event", (roomId, event) => { LogService.info("index", `Received event ${event["event_id"]} (${event["type"]}) from ${event["sender"]} in ${roomId}`); }); appservice.on("room.message", (roomId, event) => { if (!event["content"]) return; if (event["content"]["msgtype"] !== "m.text") return; const body = event["content"]["body"]; LogService.info("index", `Received message ${event["event_id"]} from ${event["sender"]} in ${roomId}: ${body}`); // We'll create fake ghosts based on the event ID. Typically these users would be mapped // by some other means and not arbitrarily. The ghost here also echos whatever the original // user said. const intent = appservice.getIntentForSuffix(event["event_id"].toLowerCase().replace(/[^a-z0-9]/g, '_')); intent.sendText(roomId, body, "m.notice"); }); appservice.on("query.user", (userId, createUser) => { // This is called when the homeserver queries a user's existence. At this point, a // user should be created. To do that, give an object or Promise of an object in the // form below to the createUser function (as shown). To prevent the creation of a user, // pass false to createUser, like so: createUser(false); LogService.info("index", `Received query for user ${userId}`); createUser({ display_name: "Test User", avatar_mxc: "mxc://localhost/somewhere", }); }); appservice.on("query.room", (roomAlias, createRoom) => { // This is called when the homeserver queries to find out if a room alias exists. At // this point, a room should be created and associated with the room alias. To do // that, given an object or Promise of an object in the form below to the createRoom // function (as shown). To prevent creation of a room, pass false to createRoom like // so: createRoom(false); The object (with minor modifications) will be passed to // the /createRoom API. LogService.info("index", `Received query for alias ${roomAlias}`); createRoom({ name: "Hello World", topic: "This is an example room", invite: [appservice.botUserId], visibility: "public", preset: "public_chat", }); }); // Note: The following 3 handlers only fire for appservice users! These will NOT be fired // for everyone. appservice.on("room.invite", (roomId, inviteEvent) => { LogService.info("index", `Received invite for ${inviteEvent["state_key"]} to ${roomId}`); }); appservice.on("room.join", (roomId, joinEvent) => { LogService.info("index", `Joined ${roomId} as ${joinEvent["state_key"]}`); }); appservice.on("room.leave", (roomId, leaveEvent) => { LogService.info("index", `Left ${roomId} as ${leaveEvent["state_key"]}`); }); appservice.begin().then(() => LogService.info("index", "Appservice started")); ================================================ FILE: examples/bot.ts ================================================ import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { AutojoinRoomsMixin, LogLevel, LogService, MatrixClient, MessageEvent, RichConsoleLogger, RustSdkCryptoStorageProvider, SimpleFsStorageProvider, } from "../src"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); LogService.muteModule("Metrics"); LogService.trace = LogService.debug; let creds = null; try { creds = require("../../examples/storage/bot.creds.json"); } catch (e) { // ignore } const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN'; const storage = new SimpleFsStorageProvider("./examples/storage/bot.json"); const crypto = new RustSdkCryptoStorageProvider("./examples/storage/bot_sqlite", StoreType.Sqlite); const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); AutojoinRoomsMixin.setupOnClient(client); (async function() { await client.dms.update(); // should update in `start()`, but we're earlier than that here const targetRoomId = await client.dms.getOrCreateDm(dmTarget); client.on("room.message", async (roomId: string, event: any) => { if (roomId !== targetRoomId) return; const message = new MessageEvent(event); if (message.messageType !== "m.text") return; if (message.textBody.startsWith("!ping")) { await client.replyNotice(roomId, event, "Pong from DM"); } }); LogService.info("index", "Starting bot..."); await client.start(); })(); ================================================ FILE: examples/encryption_appservice.ts ================================================ import * as fs from "fs"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { Appservice, EncryptionAlgorithm, FileMessageEventContent, IAppserviceOptions, IAppserviceRegistration, LogLevel, LogService, MessageEvent, RichConsoleLogger, RustSdkAppserviceCryptoStorageProvider, SimpleFsStorageProvider, SimpleRetryJoinStrategy, } from "../src"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); LogService.muteModule("Metrics"); LogService.trace = LogService.debug; let creds = null; try { creds = require("../../examples/storage/encryption_appservice.creds.json"); } catch (e) { // ignore } const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const storage = new SimpleFsStorageProvider("./examples/storage/encryption_appservice.json"); const crypto = new RustSdkAppserviceCryptoStorageProvider("./examples/storage/encryption_appservice_sqlite", StoreType.Sqlite); const worksImage = fs.readFileSync("./examples/static/it-works.png"); const registration: IAppserviceRegistration = { "as_token": creds?.['asToken'] ?? "change_me", "hs_token": creds?.['hsToken'] ?? "change_me", "sender_localpart": "crypto_main_bot_user2", "namespaces": { users: [{ regex: "@crypto.*:localhost", exclusive: true, }], rooms: [], aliases: [], }, "de.sorunome.msc2409.push_ephemeral": true, }; const options: IAppserviceOptions = { bindAddress: "0.0.0.0", port: 9000, homeserverName: "localhost", homeserverUrl: homeserverUrl, storage: storage, registration: registration, joinStrategy: new SimpleRetryJoinStrategy(), cryptoStorage: crypto, intentOptions: { encryption: true, }, }; const appservice = new Appservice(options); const bot = appservice.botIntent; // const bot = appservice.getIntentForUserId("@crypto_bot1:localhost"); (async function() { await bot.enableEncryption(); let encryptedRoomId: string; const joinedRooms = await bot.underlyingClient.getJoinedRooms(); for (const roomId of joinedRooms) { if (await bot.underlyingClient.crypto.isRoomEncrypted(roomId)) { const members = await bot.underlyingClient.getJoinedRoomMembers(roomId); if (members.length >= 2) { encryptedRoomId = roomId; break; } } } if (!encryptedRoomId) { encryptedRoomId = await bot.underlyingClient.createRoom({ invite: [dmTarget], is_direct: true, visibility: "private", preset: "trusted_private_chat", initial_state: [ { type: "m.room.encryption", state_key: "", content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 } }, { type: "m.room.guest_access", state_key: "", content: { guest_access: "can_join" } }, ], }); } appservice.on("query.key_claim", (req, done) => { LogService.info("index", "Key claim request:", req); done({}); }); appservice.on("query.key", (req, done) => { LogService.info("index", "Key query request:", req); done({}); }); appservice.on("room.failed_decryption", async (roomId: string, event: any, e: Error) => { LogService.error("index", `Failed to decrypt ${roomId} ${event['event_id']} because `, e); }); appservice.on("room.message", async (roomId: string, event: any) => { if (roomId !== encryptedRoomId) return; const message = new MessageEvent(event); if (message.sender === bot.userId && message.messageType === "m.notice") { // yay, we decrypted our own message. Communicate that back for testing purposes. const encrypted = await bot.underlyingClient.crypto.encryptMedia(Buffer.from(worksImage)); const mxc = await bot.underlyingClient.uploadContent(encrypted.buffer); await bot.underlyingClient.sendMessage(roomId, { msgtype: "m.image", body: "it-works.png", info: { // XXX: We know these details, so have hardcoded them. w: 256, h: 256, mimetype: "image/png", size: worksImage.length, }, file: { url: mxc, ...encrypted.file, }, }); return; } if (message.messageType === "m.image") { const fileEvent = new MessageEvent(message.raw); const decrypted = await bot.underlyingClient.crypto.decryptMedia(fileEvent.content.file); fs.writeFileSync('./examples/storage/decrypted.png', decrypted); await bot.underlyingClient.unstableApis.addReactionToEvent(roomId, fileEvent.eventId, 'Decrypted'); return; } if (message.messageType !== "m.text") return; if (message.textBody.startsWith("!ping")) { await bot.underlyingClient.replyNotice(roomId, event, "Pong"); } }); LogService.info("index", "Starting appservice..."); await appservice.begin(); })(); ================================================ FILE: examples/encryption_bot.ts ================================================ import * as fs from "fs"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { EncryptionAlgorithm, FileMessageEventContent, LogLevel, LogService, MatrixClient, MessageEvent, RichConsoleLogger, RustSdkCryptoStorageProvider, SimpleFsStorageProvider, } from "../src"; LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.TRACE); LogService.muteModule("Metrics"); LogService.trace = LogService.debug; let creds = null; try { creds = require("../../examples/storage/encryption_bot.creds.json"); } catch (e) { // ignore } const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost"; const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008"; const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN'; const storage = new SimpleFsStorageProvider("./examples/storage/encryption_bot.json"); const crypto = new RustSdkCryptoStorageProvider("./examples/storage/encryption_bot_sqlite", StoreType.Sqlite); const worksImage = fs.readFileSync("./examples/static/it-works.png"); const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto); (async function() { let encryptedRoomId: string; const joinedRooms = await client.getJoinedRooms(); await client.crypto.prepare(joinedRooms); // init crypto because we're doing things before the client is started for (const roomId of joinedRooms) { if (await client.crypto.isRoomEncrypted(roomId)) { encryptedRoomId = roomId; break; } } if (!encryptedRoomId) { encryptedRoomId = await client.createRoom({ invite: [dmTarget], is_direct: true, visibility: "private", preset: "trusted_private_chat", initial_state: [ { type: "m.room.encryption", state_key: "", content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 } }, { type: "m.room.guest_access", state_key: "", content: { guest_access: "can_join" } }, ], }); } client.on("room.failed_decryption", async (roomId: string, event: any, e: Error) => { LogService.error("index", `Failed to decrypt ${roomId} ${event['event_id']} because `, e); }); client.on("room.message", async (roomId: string, event: any) => { if (roomId !== encryptedRoomId) return; const message = new MessageEvent(event); if (message.sender === (await client.getUserId()) && message.messageType === "m.notice") { // yay, we decrypted our own message. Communicate that back for testing purposes. const encrypted = await client.crypto.encryptMedia(Buffer.from(worksImage)); const mxc = await client.uploadContent(encrypted.buffer); await client.sendMessage(roomId, { msgtype: "m.image", body: "it-works.png", info: { // XXX: We know these details, so have hardcoded them. w: 256, h: 256, mimetype: "image/png", size: worksImage.length, }, file: { url: mxc, ...encrypted.file, }, }); return; } if (message.messageType === "m.image") { const fileEvent = new MessageEvent(message.raw); const decrypted = await client.crypto.decryptMedia(fileEvent.content.file); fs.writeFileSync('./examples/storage/decrypted.png', decrypted); await client.unstableApis.addReactionToEvent(roomId, fileEvent.eventId, 'Decrypted'); return; } if (message.messageType !== "m.text") return; if (message.textBody.startsWith("!ping")) { await client.replyNotice(roomId, event, "Pong"); } }); LogService.info("index", "Starting bot..."); await client.start(); })(); ================================================ FILE: examples/login_register.ts ================================================ import { LogService, MatrixAuth } from "../src"; // CAUTION: This logs a lot of secrets the console, including the password. Use with caution. const homeserverUrl = "http://localhost:8008"; const password = "P@ssw0rd"; const username = `example_user_${new Date().getTime()}`; const auth = new MatrixAuth(homeserverUrl); auth.passwordRegister(username, password).then(client => { return client.getUserId(); }).then(userId => { LogService.info("index", "Registered as " + userId + " - Trying to log in now"); return auth.passwordLogin(username, password); }).then(client => { return client.getUserId(); }).then(userId => { LogService.info("index", "Logged in as " + userId); }).catch(err => { LogService.error("index", err); }); ================================================ FILE: jsdoc.json ================================================ { "tags": { "allowUnknownTags": true }, "plugins": [ "node_modules/better-docs/category", "node_modules/better-docs/typescript" ], "source": { "include": [ "src" ], "includePattern": ".ts$" }, "opts": { "encoding": "utf8", "destination": ".jsdoc", "readme": "docs/index.md", "recurse": true, "verbose": true, "template": "node_modules/better-docs" } } ================================================ FILE: package.json ================================================ { "name": "matrix-bot-sdk", "version": "develop", "description": "TypeScript/JavaScript SDK for Matrix bots and appservices", "repository": { "type": "git", "url": "git+https://github.com/turt2live/matrix-bot-sdk.git" }, "author": "turt2live", "license": "MIT", "bugs": { "url": "https://github.com/turt2live/matrix-bot-sdk/issues" }, "homepage": "https://github.com/turt2live/matrix-bot-sdk#readme", "scripts": { "prepublishOnly": "yarn build", "docs": "jsdoc -c jsdoc.json -P package.json -u docs/tutorials", "build": "tsc --listEmittedFiles -p tsconfig-release.json", "lint": "eslint \"{src,test,examples}/**/*.ts\"", "lint:fix": "eslint \"{src,test,examples}/**/*.ts\" --fix", "test": "jest", "build:examples": "tsc -p tsconfig-examples.json", "example:bot": "yarn build:examples && node lib/examples/bot.js", "example:appservice": "yarn build:examples && node lib/examples/appservice.js", "example:login_register": "yarn build:examples && node lib/examples/login_register.js", "example:encryption_bot": "yarn build:examples && node lib/examples/encryption_bot.js", "example:encryption_appservice": "yarn build:examples && node lib/examples/encryption_appservice.js" }, "main": "./lib/index.js", "typings": "./lib/index.d.ts", "engines": { "node": ">=22.0.0" }, "keywords": [ "matrix", "bot", "sdk", "js", "ts", "node", "helpers", "snippets", "chat", "modules", "bot-sdk", "appservices" ], "files": [ "src/*", "lib/*", "scripts/*", "tsconfig.json" ], "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "@types/express": "^4.17.20", "another-json": "^0.2.0", "async-lock": "^1.4.0", "chalk": "4", "express": "^4.18.2", "glob-to-regexp": "^0.4.1", "hash.js": "^1.1.7", "html-to-text": "^9.0.5", "htmlencode": "^0.0.4", "lowdb": "1", "lru-cache": "^10.0.1", "mkdirp": "^3.0.1", "morgan": "^1.10.0", "postgres": "^3.4.1", "request": "^2.88.2", "request-promise": "^4.2.6", "sanitize-html": "^2.11.0" }, "devDependencies": { "@babel/core": "^7.28.6", "@babel/eslint-parser": "^7.28.6", "@babel/eslint-plugin": "^7.27.1", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", "@stylistic/eslint-plugin": "^5.7.0", "@testcontainers/postgresql": "^10.2.2", "@types/async-lock": "^1.4.1", "@types/jest": "^30.0.0", "@types/lowdb": "^1.0.14", "@types/mocha": "^10.0.3", "@types/node": "22", "@types/request-promise": "^4.1.51", "@types/sanitize-html": "^2.9.3", "@types/simple-mock": "^0.8.4", "@typescript-eslint/eslint-plugin": "^8.53.0", "@typescript-eslint/parser": "^8.53.0", "better-docs": "^2.7.3", "eslint": "^9.39.2", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-deprecate": "^0.8.7", "eslint-plugin-import": "^2.32.0", "eslint-plugin-matrix-org": "^3.0.0", "eslint-plugin-unicorn": "^62.0.0", "get-port": "5", "globals": "^17.0.0", "jest": "^30.2.0", "jsdoc": "^4.0.5", "matrix-mock-request": "^2.6.0", "simple-mock": "^0.8.0", "taffydb": "^2.7.3", "testcontainers": "^10.2.2", "tmp": "^0.2.1", "ts-jest": "^29.4.6", "typescript": "^5.9.3" }, "jest": { "preset": "ts-jest", "testEnvironment": "node", "testMatch": [ "/test/**/*Test.ts" ] } } ================================================ FILE: src/AdminApis.ts ================================================ import { MatrixClient } from "./MatrixClient"; import { SynapseAdminApis } from "./SynapseAdminApis"; /** * Whois information about a user. * See https://matrix.org/docs/spec/client_server/r0.5.0#get-matrix-client-r0-admin-whois-userid for more information. * @category Admin APIs */ export interface WhoisInfo { user_id: string; devices: { [device_id: string]: { sessions: [{ connections: WhoisConnectionInfo[]; }]; }; }; } interface WhoisConnectionInfo { /** * Most recently seen IP address of the session. */ ip: string; /** * Unix timestamp that the session was last active. */ last_seen: number; /** * User agent string last seen in the session. */ user_agent: string; } /** * Access to various administrative APIs. * @category Admin APIs */ export class AdminApis { constructor(private client: MatrixClient) { } /** * Gets access to the Synapse administrative APIs object. */ public get synapse(): SynapseAdminApis { return new SynapseAdminApis(this.client); } /** * Gets information about a particular user. * @param {string} userId the user ID to lookup * @returns {Promise} resolves to the whois information */ public whoisUser(userId: string): Promise { return this.client.doRequest("GET", "/_matrix/client/v3/admin/whois/" + encodeURIComponent(userId)); } } ================================================ FILE: src/DMs.ts ================================================ import { MatrixClient } from "./MatrixClient"; import { EncryptionAlgorithm } from "./models/Crypto"; import { LogService } from "./logging/LogService"; /** * Handles DM (direct messages) matching between users. Note that bots which * existed prior to this might not have DM rooms populated correctly - the * account data can be populated externally and that will be reflected here. * * Note that DM status is persisted across all access tokens for a user and * is not persisted with the regular stores. The DM map is instead tracked * on the homeserver as account data and thus survives the bot's own storage * being wiped. * @category Utilities */ export class DMs { private cached = new Map(); private ready: Promise; /** * Creates a new DM map. * @param {MatrixClient} client The client the DM map is for. */ public constructor(private client: MatrixClient) { this.client.on("account_data", (ev) => { if (ev['type'] !== 'm.direct') return; // noinspection JSIgnoredPromiseFromCall this.updateFromAccountData(); }); this.client.on("room.invite", (rid, ev) => this.handleInvite(rid, ev)); } private async updateFromAccountData() { // Don't trust the sync update let map = {}; try { map = await this.client.getAccountData("m.direct"); } catch (e) { if (e.body?.errcode !== "M_NOT_FOUND" && e.statusCode !== 404) { LogService.warn("DMs", "Error getting m.direct account data: ", e); } } this.cached = new Map(); for (const [userId, roomIds] of Object.entries(map)) { this.cached.set(userId, roomIds as string[]); } } private async handleInvite(roomId: string, ev: any) { if (ev['content']?.['is_direct'] === true) { const userId = ev['sender']; if (!this.cached.has(userId)) this.cached.set(userId, []); this.cached.set(userId, [roomId, ...this.cached.get(userId)]); await this.persistCache(); } } private async persistCache() { const obj: Record = {}; for (const [uid, rids] of this.cached.entries()) { obj[uid] = rids; } await this.client.setAccountData("m.direct", obj); } private async fixDms(userId: string) { const currentRooms = this.cached.get(userId); if (!currentRooms) return; const toKeep: string[] = []; for (const roomId of currentRooms) { try { const members = await this.client.getAllRoomMembers(roomId); const joined = members.filter(m => m.effectiveMembership === "join" || m.effectiveMembership === "invite"); if (joined.some(m => m.membershipFor === userId)) { toKeep.push(roomId); } } catch (e) { LogService.warn("DMs", `Unable to check ${roomId} for room members - assuming invalid DM`); } } if (toKeep.length === currentRooms.length) return; // no change if (toKeep.length > 0) { this.cached.set(userId, toKeep); } else { this.cached.delete(userId); } await this.persistCache(); } /** * Forces an update of the DM cache. * @returns {Promise} Resolves when complete. */ public async update(): Promise { await this.ready; // finish the existing call if present this.ready = this.updateFromAccountData(); return this.ready; } /** * Gets or creates a DM with a given user. If a DM needs to be created, it will * be created as an encrypted DM (if both the MatrixClient and target user support * crypto). Otherwise, the createFn can be used to override the call. Note that * when creating a DM room the room should have `is_direct: true` set. * @param {string} userId The user ID to get/create a DM for. * @param {Function} createFn Optional function to use to create the room. Resolves * to the created room ID. * @returns {Promise} Resolves to the DM room ID. */ public async getOrCreateDm(userId: string, createFn?: (targetUserId: string) => Promise): Promise { await this.ready; await this.fixDms(userId); const rooms = this.cached.get(userId); if (rooms?.length) return rooms[0]; let roomId: string; if (createFn) { roomId = await createFn(userId); } else { let hasKeys = false; if (!!this.client.crypto) { const keys = await this.client.getUserDevices([userId]); const userKeys = keys?.device_keys?.[userId] ?? {}; hasKeys = Object.values(userKeys).filter(device => Object.values(device).length > 0).length > 0; } roomId = await this.client.createRoom({ invite: [userId], is_direct: true, preset: "trusted_private_chat", initial_state: hasKeys ? [{ type: "m.room.encryption", state_key: "", content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 }, }] : [], }); } if (!this.cached.has(userId)) this.cached.set(userId, []); this.cached.set(userId, [roomId, ...this.cached.get(userId)]); await this.persistCache(); return roomId; } /** * Determines if a given room is a DM according to the cache. * @param {string} roomId The room ID. * @returns {boolean} True if the room ID is a cached DM room ID. */ public isDm(roomId: string): boolean { for (const val of this.cached.values()) { if (val.includes(roomId)) { return true; } } return false; } } ================================================ FILE: src/IFilter.ts ================================================ /** * Sync filter information. */ export interface IFilterInfo { id: number; filter: any; // TODO: Define what a filter is } ================================================ FILE: src/MatrixAuth.ts ================================================ import { MatrixClient } from "./MatrixClient"; /** * Functions for interacting with Matrix prior to having an access token. Intended * to be used for logging in/registering to get a MatrixClient instance. * * By design, this limits the options used to create the MatrixClient. To specify * custom elements to the client, get the access token from the returned client * and create a new MatrixClient instance. Due to the nature of Matrix, it is * also recommended to use the homeserverUrl from the generated MatrixClient as * it may be different from that given to the MatrixAuth class. */ export class MatrixAuth { /** * Creates a new MatrixAuth class for creating a MatrixClient * @param {string} homeserverUrl The homeserver URL to authenticate against. */ public constructor(private homeserverUrl: string) { // nothing to do } /** * Generate a client with no access token so we can reuse the doRequest * logic already written. */ private createTemplateClient(): MatrixClient { return new MatrixClient(this.homeserverUrl, ""); } /** * Performs simple registration using a password for the account. This will * assume the server supports the m.login.password flow for registration, and * will attempt to complete only that stage. The caller is expected to determine * if the homeserver supports registration prior to invocation. * @param {string} localpart The localpart (username) to register * @param {string} password The password to register with * @param {string} deviceName The name of the newly created device. Optional. * @returns {Promise} Resolves to a logged-in MatrixClient */ public async passwordRegister(localpart: string, password: string, deviceName?: string): Promise { // First try and complete the stage without UIA in hopes the server is kind to us: const body = { username: localpart, password: password, initial_device_display_name: deviceName, }; let response; try { response = await this.createTemplateClient().doRequest("POST", "/_matrix/client/v3/register", null, body); } catch (e) { if (e.statusCode === 401) { if (typeof (e.body) === "string") e.body = JSON.parse(e.body); if (!e.body) throw new Error(JSON.stringify(Object.keys(e))); // 401 means we need to do UIA, so try and complete a stage const sessionId = e.body['session']; const expectedFlow = ["m.login.dummy"]; let hasFlow = false; for (const flow of e.body['flows']) { const stages = flow['stages']; if (stages.length !== expectedFlow.length) continue; let stagesMatch = true; for (let i = 0; i < stages.length; i++) { if (stages[i] !== expectedFlow[i]) { stagesMatch = false; break; } } if (stagesMatch) { hasFlow = true; break; } } if (!hasFlow) throw new Error("Failed to find appropriate login flow in User-Interactive Authentication"); body['auth'] = { type: expectedFlow[0], // HACK: We assume we only have one entry here session: sessionId, }; response = await this.createTemplateClient().doRequest("POST", "/_matrix/client/v3/register", null, body); } } if (!response) throw new Error("Failed to register"); const accessToken = response['access_token']; if (!accessToken) throw new Error("No access token returned"); return new MatrixClient(this.homeserverUrl, accessToken); } /** * Performs simple password login with the homeserver. The caller is * expected to confirm if the homeserver supports this login flow prior * to invocation. * @param {string} username The username (localpart or user ID) to log in with * @param {string} password The password for the account * @param {string} deviceName The name of the newly created device. Optional. * @returns {Promise} Resolves to a logged-in MatrixClient */ public async passwordLogin(username: string, password: string, deviceName?: string): Promise { const body = { type: "m.login.password", identifier: { type: "m.id.user", user: username, }, password: password, initial_device_display_name: deviceName, }; const response = await this.createTemplateClient().doRequest("POST", "/_matrix/client/v3/login", null, body); const accessToken = response["access_token"]; if (!accessToken) throw new Error("Expected access token in response - got nothing"); let homeserverUrl = this.homeserverUrl; if (response['well_known'] && response['well_known']['m.homeserver'] && response['well_known']['m.homeserver']['base_url']) { homeserverUrl = response['well_known']['m.homeserver']['base_url']; } return new MatrixClient(homeserverUrl, accessToken); } } ================================================ FILE: src/MatrixClient.ts ================================================ import { EventEmitter } from "events"; import { htmlEncode } from "htmlencode"; import { htmlToText } from "html-to-text"; import { IStorageProvider } from "./storage/IStorageProvider"; import { MemoryStorageProvider } from "./storage/MemoryStorageProvider"; import { IJoinRoomStrategy } from "./strategies/JoinRoomStrategy"; import { UnstableApis } from "./UnstableApis"; import { IPreprocessor } from "./preprocessors/IPreprocessor"; import { getRequestFn } from "./request"; import { extractRequestError, LogService } from "./logging/LogService"; import { RichReply } from "./helpers/RichReply"; import { Metrics } from "./metrics/Metrics"; import { timedMatrixClientFunctionCall } from "./metrics/decorators"; import { AdminApis } from "./AdminApis"; import { Presence } from "./models/Presence"; import { Membership, MembershipEvent } from "./models/events/MembershipEvent"; import { RoomEvent, RoomEventContent, StateEvent } from "./models/events/RoomEvent"; import { EventContext } from "./models/EventContext"; import { PowerLevelBounds } from "./models/PowerLevelBounds"; import { EventKind } from "./models/events/EventKind"; import { IdentityClient } from "./identity/IdentityClient"; import { OpenIDConnectToken } from "./models/OpenIDConnect"; import { doHttpRequest } from "./http"; import { Space, SpaceCreateOptions } from "./models/Spaces"; import { PowerLevelAction } from "./models/PowerLevelAction"; import { CryptoClient } from "./e2ee/CryptoClient"; import { FallbackKey, IToDeviceMessage, MultiUserDeviceListResponse, OTKAlgorithm, OTKClaimResponse, OTKCounts, OTKs, OwnUserDevice, } from "./models/Crypto"; import { requiresCrypto } from "./e2ee/decorators"; import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider"; import { EncryptedRoomEvent } from "./models/events/EncryptedRoomEvent"; import { IWhoAmI } from "./models/Account"; import { RustSdkCryptoStorageProvider } from "./storage/RustSdkCryptoStorageProvider"; import { DMs } from "./DMs"; import { ServerVersions } from "./models/ServerVersions"; import { RoomCreateOptions } from "./models/CreateRoom"; import { PresenceState } from './models/events/PresenceEvent'; const SYNC_BACKOFF_MIN_MS = 5000; const SYNC_BACKOFF_MAX_MS = 15000; const VERSIONS_CACHE_MS = 7200000; // 2 hours /** * A client that is capable of interacting with a matrix homeserver. */ export class MatrixClient extends EventEmitter { /** * The presence status to use while syncing. The valid values are "online" to set the account as online, * "offline" to set the user as offline, "unavailable" for marking the user away, and null for not setting * an explicit presence (the default). * * Has no effect if the client is not syncing. Does not apply until the next sync request. */ public syncingPresence: PresenceState | null = null; /** * The number of milliseconds to wait for new events for on the next sync. * * Has no effect if the client is not syncing. Does not apply until the next sync request. */ public syncingTimeout = 30000; /** * The crypto manager instance for this client. Generally speaking, this shouldn't * need to be accessed but is made available. * * Will be null/undefined if crypto is not possible. */ public readonly crypto: CryptoClient; /** * The DM manager instance for this client. */ public readonly dms: DMs; private userId: string; private requestId = 0; private lastJoinedRoomIds: string[] = []; private impersonatedUserId: string; private impersonatedDeviceId: string; private joinStrategy: IJoinRoomStrategy = null; private eventProcessors: { [eventType: string]: IPreprocessor[] } = {}; private filterId = 0; private stopSyncing = false; private metricsInstance: Metrics = new Metrics(); private unstableApisInstance = new UnstableApis(this); private cachedVersions: ServerVersions; private versionsLastFetched = 0; /** * Set this to true to have the client only persist the sync token after the sync * has been processed successfully. Note that if this is true then when the sync * loop throws an error the client will not persist a token. */ protected persistTokenAfterSync = false; /** * Creates a new matrix client * @param {string} homeserverUrl The homeserver's client-server API URL * @param {string} accessToken The access token for the homeserver * @param {IStorageProvider} storage The storage provider to use. Defaults to MemoryStorageProvider. * @param {ICryptoStorageProvider} cryptoStore Optional crypto storage provider to use. If not supplied, * end-to-end encryption will not be functional in this client. */ constructor( public readonly homeserverUrl: string, public readonly accessToken: string, private storage: IStorageProvider = null, public readonly cryptoStore: ICryptoStorageProvider = null, ) { super(); if (this.homeserverUrl.endsWith("/")) { this.homeserverUrl = this.homeserverUrl.substring(0, this.homeserverUrl.length - 1); } if (this.cryptoStore) { if (!this.storage || this.storage instanceof MemoryStorageProvider) { LogService.warn("MatrixClientLite", "Starting an encryption-capable client with a memory store is not considered a good idea."); } if (!(this.cryptoStore instanceof RustSdkCryptoStorageProvider)) { throw new Error("Cannot support custom encryption stores: Use a RustSdkCryptoStorageProvider"); } this.crypto = new CryptoClient(this); this.on("room.event", (roomId, event) => { // noinspection JSIgnoredPromiseFromCall this.crypto.onRoomEvent(roomId, event); }); this.on("room.join", (roomId) => { // noinspection JSIgnoredPromiseFromCall this.crypto.onRoomJoin(roomId); }); LogService.debug("MatrixClientLite", "End-to-end encryption client created"); } else { // LogService.trace("MatrixClientLite", "Not setting up encryption"); } if (!this.storage) this.storage = new MemoryStorageProvider(); this.dms = new DMs(this); } /** * The storage provider for this client. Direct access is usually not required. */ public get storageProvider(): IStorageProvider { return this.storage; } /** * The metrics instance for this client */ public get metrics(): Metrics { return this.metricsInstance; } /** * Assigns a new metrics instance, overwriting the old one. * @param {Metrics} metrics The new metrics instance. */ public set metrics(metrics: Metrics) { if (!metrics) throw new Error("Metrics cannot be null/undefined"); this.metricsInstance = metrics; } /** * Gets the unstable API access class. This is generally not recommended to be * used by clients. * @return {UnstableApis} The unstable API access class. */ public get unstableApis(): UnstableApis { return this.unstableApisInstance; } /** * Gets the admin API access class. * @return {AdminApis} The admin API access class. */ public get adminApis(): AdminApis { return new AdminApis(this); } /** * Sets a user ID to impersonate as. This will assume that the access token for this client * is for an application service, and that the userId given is within the reach of the * application service. Setting this to null will stop future impersonation. The user ID is * assumed to already be valid * @param {string} userId The user ID to masquerade as, or `null` to clear masquerading. * @param {string} deviceId Optional device ID to impersonate under the given user, if supported * by the server. Check the whoami response after setting. */ public impersonateUserId(userId: string | null, deviceId?: string): void { this.impersonatedUserId = userId; this.userId = userId; if (userId) { this.impersonatedDeviceId = deviceId; } else if (deviceId) { throw new Error("Cannot impersonate just a device: need a user ID"); } else { this.impersonatedDeviceId = null; } } /** * Acquires an identity server client for communicating with an identity server. Note that * this will automatically do the login portion to establish a usable token with the identity * server provided, but it will not automatically accept any terms of service. * * The identity server name provided will in future be resolved to a server address - for now * that resolution is assumed to be prefixing the name with `https://`. * @param {string} identityServerName The domain of the identity server to connect to. * @returns {Promise} Resolves to a prepared identity client. */ public async getIdentityServerClient(identityServerName: string): Promise { const oidcToken = await this.getOpenIDConnectToken(); return IdentityClient.acquire(oidcToken, `https://${identityServerName}`, this); } /** * Sets the strategy to use for when joinRoom is called on this client * @param {IJoinRoomStrategy} strategy The strategy to use, or null to use none */ public setJoinStrategy(strategy: IJoinRoomStrategy): void { this.joinStrategy = strategy; } /** * Adds a preprocessor to the event pipeline. When this client encounters an event, it * will try to run it through the preprocessors it can in the order they were added. * @param {IPreprocessor} preprocessor the preprocessor to add */ public addPreprocessor(preprocessor: IPreprocessor): void { if (!preprocessor) throw new Error("Preprocessor cannot be null"); const eventTypes = preprocessor.getSupportedEventTypes(); if (!eventTypes) return; // Nothing to do for (const eventType of eventTypes) { if (!this.eventProcessors[eventType]) this.eventProcessors[eventType] = []; this.eventProcessors[eventType].push(preprocessor); } } private async processEvent(event: any): Promise { if (!event) return event; if (!this.eventProcessors[event["type"]]) return event; for (const processor of this.eventProcessors[event["type"]]) { await processor.processEvent(event, this, EventKind.RoomEvent); } return event; } /** * Retrieves the server's supported specification versions and unstable features. * @returns {Promise} Resolves to the server's supported versions. */ @timedMatrixClientFunctionCall() public async getServerVersions(): Promise { if (!this.cachedVersions || (Date.now() - this.versionsLastFetched) >= VERSIONS_CACHE_MS) { this.cachedVersions = await this.doRequest("GET", "/_matrix/client/versions"); this.versionsLastFetched = Date.now(); } return this.cachedVersions; } /** * Determines if the server supports a given unstable feature flag. Useful for determining * if the server can support an unstable MSC. * @param {string} feature The feature name to look for. * @returns {Promise} Resolves to true if the server supports the flag, false otherwise. */ public async doesServerSupportUnstableFeature(feature: string): Promise { return !!(await this.getServerVersions()).unstable_features?.[feature]; } /** * Determines if the server supports a given version of the specification or not. * @param {string} version The version to look for. Eg: "v1.1" * @returns {Promise} Resolves to true if the server supports the version, false otherwise. */ public async doesServerSupportVersion(version: string): Promise { return (await this.getServerVersions()).versions.includes(version); } /** * Determines if the server supports at least one of the given specification versions or not. * @param {string[]} versions The versions to look for. Eg: ["v1.1"] * @returns {Promise} Resolves to true if the server supports any of the versions, false otherwise. */ public async doesServerSupportAnyOneVersion(versions: string[]): Promise { for (const version of versions) { if (await this.doesServerSupportVersion(version)) { return true; } } return false; } /** * Retrieves an OpenID Connect token from the homeserver for the current user. * @returns {Promise} Resolves to the token. */ @timedMatrixClientFunctionCall() public async getOpenIDConnectToken(): Promise { const userId = encodeURIComponent(await this.getUserId()); return this.doRequest("POST", "/_matrix/client/v3/user/" + userId + "/openid/request_token", null, {}); } /** * Retrieves content from account data. * @param {string} eventType The type of account data to retrieve. * @returns {Promise} Resolves to the content of that account data. */ @timedMatrixClientFunctionCall() public async getAccountData(eventType: string): Promise { const userId = encodeURIComponent(await this.getUserId()); eventType = encodeURIComponent(eventType); return this.doRequest("GET", "/_matrix/client/v3/user/" + userId + "/account_data/" + eventType); } /** * Retrieves content from room account data. * @param {string} eventType The type of room account data to retrieve. * @param {string} roomId The room to read the account data from. * @returns {Promise} Resolves to the content of that account data. */ @timedMatrixClientFunctionCall() public async getRoomAccountData(eventType: string, roomId: string): Promise { const userId = encodeURIComponent(await this.getUserId()); eventType = encodeURIComponent(eventType); roomId = encodeURIComponent(roomId); return this.doRequest("GET", "/_matrix/client/v3/user/" + userId + "/rooms/" + roomId + "/account_data/" + eventType); } /** * Retrieves content from account data. If the account data request throws an error, * this simply returns the default provided. * @param {string} eventType The type of account data to retrieve. * @param {any} defaultContent The default value. Defaults to null. * @returns {Promise} Resolves to the content of that account data, or the default. */ @timedMatrixClientFunctionCall() public async getSafeAccountData(eventType: string, defaultContent: T = null): Promise { try { return await this.getAccountData(eventType); } catch (e) { LogService.warn("MatrixClient", `Error getting ${eventType} account data:`, extractRequestError(e)); return defaultContent; } } /** * Retrieves content from room account data. If the account data request throws an error, * this simply returns the default provided. * @param {string} eventType The type of room account data to retrieve. * @param {string} roomId The room to read the account data from. * @param {any} defaultContent The default value. Defaults to null. * @returns {Promise} Resolves to the content of that room account data, or the default. */ @timedMatrixClientFunctionCall() public async getSafeRoomAccountData(eventType: string, roomId: string, defaultContent: T = null): Promise { try { return await this.getRoomAccountData(eventType, roomId); } catch (e) { LogService.warn("MatrixClient", `Error getting ${eventType} room account data in ${roomId}:`, extractRequestError(e)); return defaultContent; } } /** * Sets account data. * @param {string} eventType The type of account data to set * @param {any} content The content to set * @returns {Promise} Resolves when updated */ @timedMatrixClientFunctionCall() public async setAccountData(eventType: string, content: any): Promise { const userId = encodeURIComponent(await this.getUserId()); eventType = encodeURIComponent(eventType); return this.doRequest("PUT", "/_matrix/client/v3/user/" + userId + "/account_data/" + eventType, null, content); } /** * Sets room account data. * @param {string} eventType The type of room account data to set * @param {string} roomId The room to set account data in * @param {any} content The content to set * @returns {Promise} Resolves when updated */ @timedMatrixClientFunctionCall() public async setRoomAccountData(eventType: string, roomId: string, content: any): Promise { const userId = encodeURIComponent(await this.getUserId()); eventType = encodeURIComponent(eventType); roomId = encodeURIComponent(roomId); return this.doRequest("PUT", "/_matrix/client/v3/user/" + userId + "/rooms/" + roomId + "/account_data/" + eventType, null, content); } /** * Gets the presence information for the current user. * @returns {Promise} Resolves to the presence status of the user. */ @timedMatrixClientFunctionCall() public async getPresenceStatus(): Promise { return this.getPresenceStatusFor(await this.getUserId()); } /** * Gets the presence information for a given user. * @param {string} userId The user ID to look up the presence of. * @returns {Promise} Resolves to the presence status of the user. */ @timedMatrixClientFunctionCall() public async getPresenceStatusFor(userId: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/presence/" + encodeURIComponent(userId) + "/status").then(r => new Presence(r)); } /** * Sets the presence status for the current user. * @param {PresenceState} presence The new presence state for the user. * @param {string?} statusMessage Optional status message to include with the presence. * @returns {Promise} Resolves when complete. */ @timedMatrixClientFunctionCall() public async setPresenceStatus(presence: PresenceState, statusMessage: string | undefined = undefined): Promise { return this.doRequest("PUT", "/_matrix/client/v3/presence/" + encodeURIComponent(await this.getUserId()) + "/status", null, { presence: presence, status_msg: statusMessage, }); } /** * Gets a published alias for the given room. These are supplied by the room admins * and should point to the room, but may not. This is primarily intended to be used * in the context of rendering a mention (pill) for a room. * @param {string} roomIdOrAlias The room ID or alias to get an alias for. * @returns {Promise} Resolves to a published room alias, or falsey if none found. */ @timedMatrixClientFunctionCall() public async getPublishedAlias(roomIdOrAlias: string): Promise { try { const roomId = await this.resolveRoom(roomIdOrAlias); const event = await this.getRoomStateEvent(roomId, "m.room.canonical_alias", ""); if (!event) return null; const canonical = event['alias']; const alt = event['alt_aliases'] || []; return canonical || alt[0]; } catch (e) { // Assume none return null; } } /** * Adds a new room alias to the room directory * @param {string} alias The alias to add (eg: "#my-room:matrix.org") * @param {string} roomId The room ID to add the alias to * @returns {Promise} resolves when the alias has been added */ @timedMatrixClientFunctionCall() public createRoomAlias(alias: string, roomId: string): Promise { alias = encodeURIComponent(alias); return this.doRequest("PUT", "/_matrix/client/v3/directory/room/" + alias, null, { "room_id": roomId, }); } /** * Removes a room alias from the room directory * @param {string} alias The alias to remove * @returns {Promise} resolves when the alias has been deleted */ @timedMatrixClientFunctionCall() public deleteRoomAlias(alias: string): Promise { alias = encodeURIComponent(alias); return this.doRequest("DELETE", "/_matrix/client/v3/directory/room/" + alias); } /** * Sets the visibility of a room in the directory. * @param {string} roomId The room ID to manipulate the visibility of * @param {"public" | "private"} visibility The visibility to set for the room * @return {Promise} resolves when the visibility has been updated */ @timedMatrixClientFunctionCall() public setDirectoryVisibility(roomId: string, visibility: "public" | "private"): Promise { roomId = encodeURIComponent(roomId); return this.doRequest("PUT", "/_matrix/client/v3/directory/list/room/" + roomId, null, { "visibility": visibility, }); } /** * Gets the visibility of a room in the directory. * @param {string} roomId The room ID to query the visibility of * @return {Promise<"public"|"private">} The visibility of the room */ @timedMatrixClientFunctionCall() public getDirectoryVisibility(roomId: string): Promise<"public" | "private"> { roomId = encodeURIComponent(roomId); return this.doRequest("GET", "/_matrix/client/v3/directory/list/room/" + roomId).then(response => { return response["visibility"]; }); } /** * Resolves a room ID or alias to a room ID. If the given ID or alias looks like a room ID * already, it will be returned as-is. If the room ID or alias looks like a room alias, it * will be resolved to a room ID if possible. If the room ID or alias is neither, an error * will be raised. * @param {string} roomIdOrAlias the room ID or alias to resolve to a room ID * @returns {Promise} resolves to the room ID */ @timedMatrixClientFunctionCall() public async resolveRoom(roomIdOrAlias: string): Promise { if (roomIdOrAlias.startsWith("!")) return roomIdOrAlias; // probably if (roomIdOrAlias.startsWith("#")) return this.lookupRoomAlias(roomIdOrAlias).then(r => r.roomId); throw new Error("Invalid room ID or alias"); } /** * Does a room directory lookup for a given room alias * @param {string} roomAlias the room alias to look up in the room directory * @returns {Promise} resolves to the room's information */ @timedMatrixClientFunctionCall() public lookupRoomAlias(roomAlias: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/directory/room/" + encodeURIComponent(roomAlias)).then(response => { return { roomId: response["room_id"], residentServers: response["servers"], }; }); } /** * Invites a user to a room. * @param {string} userId the user ID to invite * @param {string} roomId the room ID to invite the user to * @returns {Promise} resolves when completed */ @timedMatrixClientFunctionCall() public inviteUser(userId, roomId) { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/invite", null, { user_id: userId, }); } /** * Kicks a user from a room. * @param {string} userId the user ID to kick * @param {string} roomId the room ID to kick the user in * @param {string?} reason optional reason for the kick * @returns {Promise} resolves when completed */ @timedMatrixClientFunctionCall() public kickUser(userId, roomId, reason = null) { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/kick", null, { user_id: userId, reason: reason, }); } /** * Bans a user from a room. * @param {string} userId the user ID to ban * @param {string} roomId the room ID to set the ban in * @param {string?} reason optional reason for the ban * @returns {Promise} resolves when completed */ @timedMatrixClientFunctionCall() public banUser(userId, roomId, reason = null) { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/ban", null, { user_id: userId, reason: reason, }); } /** * Unbans a user in a room. * @param {string} userId the user ID to unban * @param {string} roomId the room ID to lift the ban in * @returns {Promise} resolves when completed */ @timedMatrixClientFunctionCall() public unbanUser(userId, roomId) { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/unban", null, { user_id: userId, }); } /** * Gets the current user ID for this client * @returns {Promise} The user ID of this client */ @timedMatrixClientFunctionCall() public async getUserId(): Promise { if (this.userId) return this.userId; // getWhoAmI should populate `this.userId` for us await this.getWhoAmI(); return this.userId; } /** * Gets the user's information from the server directly. * @returns {Promise} The "who am I" response. */ public async getWhoAmI(): Promise { const whoami = await this.doRequest("GET", "/_matrix/client/v3/account/whoami"); this.userId = whoami["user_id"]; return whoami; } /** * Stops the client from syncing. */ public stop() { this.stopSyncing = true; } /** * Starts syncing the client with an optional filter * @param {any} filter The filter to use, or null for none * @returns {Promise} Resolves when the client has started syncing */ public async start(filter: any = null): Promise { await this.dms.update(); this.stopSyncing = false; if (!filter || typeof (filter) !== "object") { LogService.trace("MatrixClientLite", "No filter given or invalid object - using defaults."); filter = null; } LogService.trace("MatrixClientLite", "Populating joined rooms to avoid excessive join emits"); this.lastJoinedRoomIds = await this.getJoinedRooms(); const userId = await this.getUserId(); if (this.crypto) { LogService.debug("MatrixClientLite", "Preparing end-to-end encryption"); await this.crypto.prepare(this.lastJoinedRoomIds); LogService.info("MatrixClientLite", "End-to-end encryption enabled"); } let createFilter = false; // noinspection ES6RedundantAwait const existingFilter = await Promise.resolve(this.storage.getFilter()); if (existingFilter) { LogService.trace("MatrixClientLite", "Found existing filter. Checking consistency with given filter"); if (JSON.stringify(existingFilter.filter) === JSON.stringify(filter)) { LogService.trace("MatrixClientLite", "Filters match"); this.filterId = existingFilter.id; } else { createFilter = true; } } else { createFilter = true; } if (createFilter && filter) { LogService.trace("MatrixClientLite", "Creating new filter"); await this.doRequest("POST", "/_matrix/client/v3/user/" + encodeURIComponent(userId) + "/filter", null, filter).then(async response => { this.filterId = response["filter_id"]; // noinspection ES6RedundantAwait await Promise.resolve(this.storage.setSyncToken(null)); // noinspection ES6RedundantAwait await Promise.resolve(this.storage.setFilter({ id: this.filterId, filter: filter, })); }); } LogService.trace("MatrixClientLite", "Starting sync with filter ID " + this.filterId); return this.startSyncInternal(); } protected startSyncInternal(): Promise { return this.startSync(); } protected async startSync(emitFn: (emitEventType: string, ...payload: any[]) => Promise = null) { // noinspection ES6RedundantAwait let token = await Promise.resolve(this.storage.getSyncToken()); const promiseWhile = async () => { if (this.stopSyncing) { LogService.info("MatrixClientLite", "Client stop requested - stopping sync"); return; } try { const response = await this.doSync(token); token = response["next_batch"]; if (!this.persistTokenAfterSync) { await Promise.resolve(this.storage.setSyncToken(token)); } LogService.debug("MatrixClientLite", "Received sync. Next token: " + token); await this.processSync(response, emitFn); if (this.persistTokenAfterSync) { await Promise.resolve(this.storage.setSyncToken(token)); } } catch (e) { // If we've requested to stop syncing, don't bother checking the error. if (this.stopSyncing) { LogService.info("MatrixClientLite", "Client stop requested - cancelling sync"); return; } LogService.error("MatrixClientLite", "Error handling sync " + extractRequestError(e)); const backoffTime = SYNC_BACKOFF_MIN_MS + Math.random() * (SYNC_BACKOFF_MAX_MS - SYNC_BACKOFF_MIN_MS); LogService.info("MatrixClientLite", `Backing off for ${backoffTime}ms`); await new Promise((r) => setTimeout(r, backoffTime)); } return promiseWhile(); }; promiseWhile(); // start the loop } @timedMatrixClientFunctionCall() protected doSync(token: string): Promise { LogService.debug("MatrixClientLite", "Performing sync with token " + token); const conf = { full_state: false, timeout: Math.max(0, this.syncingTimeout), }; // synapse complains if the variables are null, so we have to have it unset instead if (token) conf["since"] = token; if (this.filterId) conf['filter'] = this.filterId; if (this.syncingPresence) conf['presence'] = this.syncingPresence; // timeout is 40s if we have a token, otherwise 10min return this.doRequest("GET", "/_matrix/client/v3/sync", conf, null, (token ? 40000 : 600000)); } @timedMatrixClientFunctionCall() protected async processSync(raw: any, emitFn: (emitEventType: string, ...payload: any[]) => Promise = null): Promise { if (!emitFn) emitFn = (e, ...p) => Promise.resolve(this.emit(e, ...p)); if (!raw) return; // nothing to process const inbox: IToDeviceMessage[] = []; if (raw['to_device']?.['events']) { inbox.push(...raw['to_device']['events']); } for (const message of inbox) { this.emit("to-device", message); } if (this.crypto) { let unusedFallbacks: OTKAlgorithm[] = []; if (raw['org.matrix.msc2732.device_unused_fallback_key_types']) { unusedFallbacks = raw['org.matrix.msc2732.device_unused_fallback_key_types']; } else if (raw['device_unused_fallback_key_types']) { unusedFallbacks = raw['device_unused_fallback_key_types']; } const counts = raw['device_one_time_keys_count'] ?? {}; const changed = raw['device_lists']?.['changed'] ?? []; const left = raw['device_lists']?.['left'] ?? []; await this.crypto.updateSyncData(inbox, counts, unusedFallbacks, changed, left); } // Always process device messages first to ensure there are decryption keys if (raw['account_data'] && raw['account_data']['events']) { for (const event of raw['account_data']['events']) { await emitFn("account_data", event); } } if (!raw['rooms']) return; // nothing more to process const leftRooms = raw['rooms']['leave'] || {}; const inviteRooms = raw['rooms']['invite'] || {}; const joinedRooms = raw['rooms']['join'] || {}; // Process rooms we've left first for (const roomId in leftRooms) { const room = leftRooms[roomId]; if (room['account_data'] && room['account_data']['events']) { for (const event of room['account_data']['events']) { await emitFn("room.account_data", roomId, event); } } if (!room['timeline'] || !room['timeline']['events']) continue; let leaveEvent = null; for (const event of room['timeline']['events']) { if (event['type'] !== 'm.room.member') continue; if (event['state_key'] !== await this.getUserId()) continue; const membership = event["content"]?.["membership"]; if (membership !== "leave" && membership !== "ban") continue; const oldAge = leaveEvent && leaveEvent['unsigned'] && leaveEvent['unsigned']['age'] ? leaveEvent['unsigned']['age'] : 0; const newAge = event['unsigned'] && event['unsigned']['age'] ? event['unsigned']['age'] : 0; if (leaveEvent && oldAge < newAge) continue; leaveEvent = event; } if (!leaveEvent) { LogService.warn("MatrixClientLite", "Left room " + roomId + " without receiving an event"); continue; } leaveEvent = await this.processEvent(leaveEvent); await emitFn("room.leave", roomId, leaveEvent); this.lastJoinedRoomIds = this.lastJoinedRoomIds.filter(r => r !== roomId); } // Process rooms we've been invited to for (const roomId in inviteRooms) { const room = inviteRooms[roomId]; if (!room['invite_state'] || !room['invite_state']['events']) continue; let inviteEvent = null; for (const event of room['invite_state']['events']) { if (event['type'] !== 'm.room.member') continue; if (event['state_key'] !== await this.getUserId()) continue; if (!event['content']) continue; if (event['content']['membership'] !== "invite") continue; const oldAge = inviteEvent && inviteEvent['unsigned'] && inviteEvent['unsigned']['age'] ? inviteEvent['unsigned']['age'] : 0; const newAge = event['unsigned'] && event['unsigned']['age'] ? event['unsigned']['age'] : 0; if (inviteEvent && oldAge < newAge) continue; inviteEvent = event; } if (!inviteEvent) { LogService.warn("MatrixClientLite", "Invited to room " + roomId + " without receiving an event"); continue; } inviteEvent = await this.processEvent(inviteEvent); await emitFn("room.invite", roomId, inviteEvent); } // Process rooms we've joined and their events for (const roomId in joinedRooms) { const room = joinedRooms[roomId]; if (room['account_data'] && room['account_data']['events']) { for (const event of room['account_data']['events']) { await emitFn("room.account_data", roomId, event); } } if (!room['timeline'] || !room['timeline']['events']) continue; for (let event of room['timeline']['events']) { if (event['type'] === "m.room.member" && event['state_key'] === await this.getUserId()) { if (event['content']?.['membership'] === "join" && this.lastJoinedRoomIds.indexOf(roomId) === -1) { await emitFn("room.join", roomId, await this.processEvent(event)); this.lastJoinedRoomIds.push(roomId); } } event = await this.processEvent(event); if (event['type'] === 'm.room.encrypted' && await this.crypto?.isRoomEncrypted(roomId)) { await emitFn("room.encrypted_event", roomId, event); try { event = (await this.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId)).raw; event = await this.processEvent(event); await emitFn("room.decrypted_event", roomId, event); } catch (e) { LogService.error("MatrixClientLite", `Decryption error on ${roomId} ${event['event_id']}`, e); await emitFn("room.failed_decryption", roomId, event, e); } } if (event['type'] === 'm.room.message') { await emitFn("room.message", roomId, event); } if (event['type'] === 'm.room.tombstone' && event['state_key'] === '') { await emitFn("room.archived", roomId, event); } if (event['type'] === 'm.room.create' && event['state_key'] === '' && event['content'] && event['content']['predecessor'] && event['content']['predecessor']['room_id']) { await emitFn("room.upgraded", roomId, event); } await emitFn("room.event", roomId, event); } } } /** * Gets an event for a room. If the event is encrypted, and the client supports encryption, * and the room is encrypted, then this will return a decrypted event. * @param {string} roomId the room ID to get the event in * @param {string} eventId the event ID to look up * @returns {Promise} resolves to the found event */ @timedMatrixClientFunctionCall() public async getEvent(roomId: string, eventId: string): Promise { const event = await this.getRawEvent(roomId, eventId); if (event['type'] === 'm.room.encrypted' && await this.crypto?.isRoomEncrypted(roomId)) { return this.processEvent((await this.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId)).raw); } return event; } /** * Gets an event for a room. Returned as a raw event. * @param {string} roomId the room ID to get the event in * @param {string} eventId the event ID to look up * @returns {Promise} resolves to the found event */ @timedMatrixClientFunctionCall() public getRawEvent(roomId: string, eventId: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/event/" + encodeURIComponent(eventId)) .then(ev => this.processEvent(ev)); } /** * Gets the room state for the given room. Returned as raw events. * @param {string} roomId the room ID to get state for * @returns {Promise} resolves to the room's state */ @timedMatrixClientFunctionCall() public getRoomState(roomId: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/state") .then(state => Promise.all(state.map(ev => this.processEvent(ev)))); } /** * Gets the state events for a given room of a given type under the given state key. * @param {string} roomId the room ID * @param {string} type the event type * @param {String} stateKey the state key, falsey if not needed * @returns {Promise} resolves to the state event(s) * @deprecated It is not possible to get an array of events - use getRoomStateEvent instead */ @timedMatrixClientFunctionCall() public getRoomStateEvents(roomId, type, stateKey): Promise { return this.getRoomStateEvent(roomId, type, stateKey); } /** * Gets a state event for a given room of a given type under the given state key. * @param {string} roomId the room ID * @param {string} type the event type * @param {String} stateKey the state key * @returns {Promise} resolves to the state event */ @timedMatrixClientFunctionCall() public getRoomStateEvent(roomId, type, stateKey): Promise { const path = "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/state/" + encodeURIComponent(type) + "/" + encodeURIComponent(stateKey ? stateKey : ''); return this.doRequest("GET", path) .then(ev => this.processEvent(ev)); } /** * Gets the context surrounding an event. * @param {string} roomId The room ID to get the context in. * @param {string} eventId The event ID to get the context of. * @param {number} limit The maximum number of events to return on either side of the event. * @returns {Promise} The context of the event */ @timedMatrixClientFunctionCall() public async getEventContext(roomId: string, eventId: string, limit = 10): Promise { const res = await this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/context/" + encodeURIComponent(eventId), { limit }); return { event: new RoomEvent(res['event']), before: res['events_before'].map(e => new RoomEvent(e)), after: res['events_after'].map(e => new RoomEvent(e)), state: res['state'].map(e => new StateEvent(e)), }; } /** * Gets the profile for a given user * @param {string} userId the user ID to lookup * @returns {Promise} the profile of the user */ @timedMatrixClientFunctionCall() public getUserProfile(userId: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/profile/" + encodeURIComponent(userId)); } /** * Sets a new display name for the user. * @param {string} displayName the new display name for the user, or null to clear * @returns {Promise} resolves when complete */ @timedMatrixClientFunctionCall() public async setDisplayName(displayName: string): Promise { const userId = encodeURIComponent(await this.getUserId()); return this.doRequest("PUT", "/_matrix/client/v3/profile/" + userId + "/displayname", null, { displayname: displayName, }); } /** * Sets a new avatar url for the user. * @param {string} avatarUrl the new avatar URL for the user, in the form of a Matrix Content URI * @returns {Promise} resolves when complete */ @timedMatrixClientFunctionCall() public async setAvatarUrl(avatarUrl: string): Promise { const userId = encodeURIComponent(await this.getUserId()); return this.doRequest("PUT", "/_matrix/client/v3/profile/" + userId + "/avatar_url", null, { avatar_url: avatarUrl, }); } /** * Joins the given room * @param {string} roomIdOrAlias the room ID or alias to join * @param {string[]} viaServers the server names to try and join through * @returns {Promise} resolves to the joined room ID */ @timedMatrixClientFunctionCall() public async joinRoom(roomIdOrAlias: string, viaServers: string[] = []): Promise { const apiCall = (targetIdOrAlias: string) => { targetIdOrAlias = encodeURIComponent(targetIdOrAlias); const qs = {}; if (viaServers.length > 0) qs['server_name'] = viaServers; return this.doRequest("POST", "/_matrix/client/v3/join/" + targetIdOrAlias, qs, {}).then(response => { return response['room_id']; }); }; const userId = await this.getUserId(); if (this.joinStrategy) return this.joinStrategy.joinRoom(roomIdOrAlias, userId, apiCall); else return apiCall(roomIdOrAlias); } /** * Gets a list of joined room IDs * @returns {Promise} resolves to a list of room IDs the client participates in */ @timedMatrixClientFunctionCall() public getJoinedRooms(): Promise { return this.doRequest("GET", "/_matrix/client/v3/joined_rooms").then(response => response['joined_rooms']); } /** * Gets the joined members in a room. The client must be in the room to make this request. * @param {string} roomId The room ID to get the joined members of. * @returns {Promise} The joined user IDs in the room */ @timedMatrixClientFunctionCall() public getJoinedRoomMembers(roomId: string): Promise { return this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/joined_members").then(response => { return Object.keys(response['joined']); }); } /** * Gets the joined members in a room, as an object mapping userIds to profiles. The client must be in the room to make this request. * @param {string} roomId The room ID to get the joined members of. * @returns {Object} The joined user IDs in the room as an object mapped to a set of profiles. */ @timedMatrixClientFunctionCall() public async getJoinedRoomMembersWithProfiles(roomId: string): Promise<{ [userId: string]: { display_name?: string, avatar_url?: string } }> { return (await this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/joined_members")).joined; } /** * Gets the membership events of users in the room. Defaults to all membership * types, though this can be controlled with the membership and notMembership * arguments. To change the point in time, use the batchToken. * @param {string} roomId The room ID to get members in. * @param {string} batchToken The point in time to get members at (or null for 'now') * @param {string[]} membership The membership kinds to search for. * @param {string[]} notMembership The membership kinds to not search for. * @returns {Promise} Resolves to the membership events of the users in the room. * @see getRoomMembersByMembership * @see getRoomMembersWithoutMembership * @see getAllRoomMembers */ @timedMatrixClientFunctionCall() public getRoomMembers(roomId: string, batchToken: string = null, membership: Membership[] = null, notMembership: Membership[] = null): Promise { if (!membership && !notMembership) { return this.getAllRoomMembers(roomId, batchToken); } return Promise.all([ ...(membership ?? []).map(m => this.getRoomMembersAt(roomId, m, null, batchToken)), ...(notMembership ?? []).map(m => this.getRoomMembersAt(roomId, null, m, batchToken)), ]).then(r => r.reduce((p, c) => { p.push(...c); return p; }, [])).then(r => { // Shouldn't ever happen, but dedupe just in case. const vals = new Map(); for (const ev of r) { if (!vals.has(ev.membershipFor)) { vals.set(ev.membershipFor, ev); } } return Array.from(vals.values()); }); } /** * Gets all room members in the room, optionally at a given point in time. * @param {string} roomId The room ID to get members of. * @param {string} atToken Optional batch token to get members at. Leave falsy for "now". * @returns {Promise} Resolves to the member events in the room. */ @timedMatrixClientFunctionCall() public getAllRoomMembers(roomId: string, atToken?: string): Promise { return this.getRoomMembersAt(roomId, null, null, atToken); } /** * Gets the membership events of users in the room which have a particular membership type. To change * the point in time the server should return membership events at, use `atToken`. * @param {string} roomId The room ID to get members in. * @param {Membership} membership The membership to search for. * @param {string?} atToken Optional batch token to use, or null for "now". * @returns {Promise} Resolves to the membership events of the users in the room. */ @timedMatrixClientFunctionCall() public getRoomMembersByMembership(roomId: string, membership: Membership, atToken?: string): Promise { return this.getRoomMembersAt(roomId, membership, null, atToken); } /** * Gets the membership events of users in the room which lack a particular membership type. To change * the point in time the server should return membership events at, use `atToken`. * @param {string} roomId The room ID to get members in. * @param {Membership} notMembership The membership to NOT search for. * @param {string?} atToken Optional batch token to use, or null for "now". * @returns {Promise} Resolves to the membership events of the users in the room. */ @timedMatrixClientFunctionCall() public async getRoomMembersWithoutMembership(roomId: string, notMembership: Membership, atToken?: string): Promise { return this.getRoomMembersAt(roomId, null, notMembership, atToken); } private getRoomMembersAt(roomId: string, membership: Membership | null, notMembership: Membership | null, atToken: string | null): Promise { const qs = {}; if (atToken) qs["at"] = atToken; if (membership) qs["membership"] = membership; if (notMembership) qs["not_membership"] = notMembership; return this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/members", qs).then(r => { return r['chunk'].map(e => new MembershipEvent(e)); }); } /** * Leaves the given room * @param {string} roomId the room ID to leave * @param {string=} reason Optional reason to be included as the reason for leaving the room. * @returns {Promise} resolves when left */ @timedMatrixClientFunctionCall() public leaveRoom(roomId: string, reason?: string): Promise { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, { reason }); } /** * Forgets the given room * @param {string} roomId the room ID to forget * @returns {Promise<{}>} Resolves when forgotten */ @timedMatrixClientFunctionCall() public forgetRoom(roomId: string): Promise<{}> { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/forget"); } /** * Sends a read receipt for an event in a room * @param {string} roomId the room ID to send the receipt to * @param {string} eventId the event ID to set the receipt at * @returns {Promise} resolves when the receipt has been sent */ @timedMatrixClientFunctionCall() public sendReadReceipt(roomId: string, eventId: string): Promise { return this.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/receipt/m.read/" + encodeURIComponent(eventId), null, {}); } /** * Sets the typing status of the current user in a room * @param {string} roomId the room ID the user is typing in * @param {boolean} typing is the user currently typing * @param {number} timeout how long should the server preserve the typing state, in milliseconds * @returns {Promise} resolves when the typing state has been set */ @timedMatrixClientFunctionCall() public async setTyping(roomId: string, typing: boolean, timeout = 30000): Promise { const userId = await this.getUserId(); return this.doRequest("PUT", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/typing/" + encodeURIComponent(userId), null, { typing, timeout, }); } /** * Replies to a given event with the given text. The event is sent with a msgtype of m.text. * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} text the text to reply with * @param {string} html the HTML to reply with, or falsey to use the `text` * @returns {Promise} resolves to the event ID which was sent */ @timedMatrixClientFunctionCall() public replyText(roomId: string, event: any, text: string, html: string = null): Promise { if (!html) html = htmlEncode(text); const reply = RichReply.createFor(roomId, event, text, html); return this.sendMessage(roomId, reply); } /** * Replies to a given event with the given HTML. The event is sent with a msgtype of m.text. * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} html the HTML to reply with. * @returns {Promise} resolves to the event ID which was sent */ @timedMatrixClientFunctionCall() public replyHtmlText(roomId: string, event: any, html: string): Promise { const text = htmlToText(html, { wordwrap: false }); const reply = RichReply.createFor(roomId, event, text, html); return this.sendMessage(roomId, reply); } /** * Replies to a given event with the given text. The event is sent with a msgtype of m.notice. * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} text the text to reply with * @param {string} html the HTML to reply with, or falsey to use the `text` * @returns {Promise} resolves to the event ID which was sent */ @timedMatrixClientFunctionCall() public replyNotice(roomId: string, event: any, text: string, html: string = null): Promise { if (!html) html = htmlEncode(text); const reply = RichReply.createFor(roomId, event, text, html); reply['msgtype'] = 'm.notice'; return this.sendMessage(roomId, reply); } /** * Replies to a given event with the given HTML. The event is sent with a msgtype of m.notice. * The message will be encrypted if the client supports encryption and the room is encrypted. * @param {string} roomId the room ID to reply in * @param {any} event the event to reply to * @param {string} html the HTML to reply with. * @returns {Promise} resolves to the event ID which was sent */ @timedMatrixClientFunctionCall() public replyHtmlNotice(roomId: string, event: any, html: string): Promise { const text = htmlToText(html, { wordwrap: false }); const reply = RichReply.createFor(roomId, event, text, html); reply['msgtype'] = 'm.notice'; return this.sendMessage(roomId, reply); } /** * Sends a notice to the given room. The message will be encrypted if the client supports * encryption and the room is encrypted. * @param {string} roomId the room ID to send the notice to * @param {string} text the text to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendNotice(roomId: string, text: string): Promise { return this.sendMessage(roomId, { body: text, msgtype: "m.notice", }); } /** * Sends a notice to the given room with HTML content. The message will be encrypted if the client supports * encryption and the room is encrypted. * @param {string} roomId the room ID to send the notice to * @param {string} html the HTML to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendHtmlNotice(roomId: string, html: string): Promise { return this.sendMessage(roomId, { body: htmlToText(html, { wordwrap: false }), msgtype: "m.notice", format: "org.matrix.custom.html", formatted_body: html, }); } /** * Sends a text message to the given room. The message will be encrypted if the client supports * encryption and the room is encrypted. * @param {string} roomId the room ID to send the text to * @param {string} text the text to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendText(roomId: string, text: string): Promise { return this.sendMessage(roomId, { body: text, msgtype: "m.text", }); } /** * Sends a text message to the given room with HTML content. The message will be encrypted if the client supports * encryption and the room is encrypted. * @param {string} roomId the room ID to send the text to * @param {string} html the HTML to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendHtmlText(roomId: string, html: string): Promise { return this.sendMessage(roomId, { body: htmlToText(html, { wordwrap: false }), msgtype: "m.text", format: "org.matrix.custom.html", formatted_body: html, }); } /** * Sends a message to the given room. The message will be encrypted if the client supports * encryption and the room is encrypted. * @param {string} roomId the room ID to send the message to * @param {object} content the event content to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendMessage(roomId: string, content: any): Promise { return this.sendEvent(roomId, "m.room.message", content); } /** * Sends an event to the given room. This will encrypt the event before sending if the room is * encrypted and the client supports encryption. Use sendRawEvent() to avoid this behaviour. * @param {string} roomId the room ID to send the event to * @param {string} eventType the type of event to send * @param {string} content the event body to send * @returns {Promise} resolves to the event ID that represents the event */ @timedMatrixClientFunctionCall() public async sendEvent(roomId: string, eventType: string, content: any): Promise { if (await this.crypto?.isRoomEncrypted(roomId)) { content = await this.crypto.encryptRoomEvent(roomId, eventType, content); eventType = "m.room.encrypted"; } return this.sendRawEvent(roomId, eventType, content); } /** * Sends an event to the given room. * @param {string} roomId the room ID to send the event to * @param {string} eventType the type of event to send * @param {string} content the event body to send * @returns {Promise} resolves to the event ID that represents the event */ @timedMatrixClientFunctionCall() public async sendRawEvent(roomId: string, eventType: string, content: any): Promise { const txnId = (new Date().getTime()) + "__inc" + (++this.requestId); const path = "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/send/" + encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId); return this.doRequest("PUT", path, null, content).then(response => { return response['event_id']; }); } /** * Sends a state event to the given room * @param {string} roomId the room ID to send the event to * @param {string} type the event type to send * @param {string} stateKey the state key to send, should not be null * @param {string} content the event body to send * @returns {Promise} resolves to the event ID that represents the message */ @timedMatrixClientFunctionCall() public sendStateEvent(roomId: string, type: string, stateKey: string, content: any): Promise { const path = "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/state/" + encodeURIComponent(type) + "/" + encodeURIComponent(stateKey); return this.doRequest("PUT", path, null, content).then(response => { return response['event_id']; }); } /** * Redact an event in a given room * @param {string} roomId the room ID to send the redaction to * @param {string} eventId the event ID to redact * @param {String} reason an optional reason for redacting the event * @returns {Promise} resolves to the event ID that represents the redaction */ @timedMatrixClientFunctionCall() public redactEvent(roomId: string, eventId: string, reason: string | null = null): Promise { const txnId = (new Date().getTime()) + "__inc" + (++this.requestId); const content = reason !== null ? { reason } : {}; return this.doRequest("PUT", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`, null, content).then(response => { return response['event_id']; }); } /** * Creates a room. See the RoomCreateOptions interface * for more information on what to provide for `properties`. Note that creating * a room may cause the bot/appservice to raise a join event. * @param {RoomCreateOptions} properties the properties of the room. * @returns {Promise} resolves to the room ID that represents the room */ @timedMatrixClientFunctionCall() public createRoom(properties: RoomCreateOptions = {}): Promise { return this.doRequest("POST", "/_matrix/client/v3/createRoom", null, properties).then(response => { return response['room_id']; }); } /** * Checks if a given user has a required power level required to send the given event. * @param {string} userId the user ID to check the power level of * @param {string} roomId the room ID to check the power level in * @param {string} eventType the event type to look for in the `events` property of the power levels * @param {boolean} isState true to indicate the event is intended to be a state event * @returns {Promise} resolves to true if the user has the required power level, resolves to false otherwise */ @timedMatrixClientFunctionCall() public async userHasPowerLevelFor(userId: string, roomId: string, eventType: string, isState: boolean): Promise { const powerLevelsEvent = await this.getRoomStateEvent(roomId, "m.room.power_levels", ""); if (!powerLevelsEvent) { // This is technically supposed to be non-fatal, but it's pretty unreasonable for a room to be missing // power levels. throw new Error("No power level event found"); } let requiredPower = isState ? 50 : 0; if (isState && Number.isFinite(powerLevelsEvent["state_default"])) requiredPower = powerLevelsEvent["state_default"]; if (!isState && Number.isFinite(powerLevelsEvent["events_default"])) requiredPower = powerLevelsEvent["events_default"]; if (Number.isFinite(powerLevelsEvent["events"]?.[eventType])) requiredPower = powerLevelsEvent["events"][eventType]; let userPower = 0; if (Number.isFinite(powerLevelsEvent["users_default"])) userPower = powerLevelsEvent["users_default"]; if (Number.isFinite(powerLevelsEvent["users"]?.[userId])) userPower = powerLevelsEvent["users"][userId]; return userPower >= requiredPower; } /** * Checks if a given user has a required power level to perform the given action * @param {string} userId the user ID to check the power level of * @param {string} roomId the room ID to check the power level in * @param {PowerLevelAction} action the action to check power level for * @returns {Promise} resolves to true if the user has the required power level, resolves to false otherwise */ @timedMatrixClientFunctionCall() public async userHasPowerLevelForAction(userId: string, roomId: string, action: PowerLevelAction): Promise { const powerLevelsEvent = await this.getRoomStateEvent(roomId, "m.room.power_levels", ""); if (!powerLevelsEvent) { // This is technically supposed to be non-fatal, but it's pretty unreasonable for a room to be missing // power levels. throw new Error("No power level event found"); } const defaultForActions: { [A in PowerLevelAction]: number } = { [PowerLevelAction.Ban]: 50, [PowerLevelAction.Invite]: 50, [PowerLevelAction.Kick]: 50, [PowerLevelAction.RedactEvents]: 50, [PowerLevelAction.NotifyRoom]: 50, }; let requiredPower = defaultForActions[action]; let investigate = powerLevelsEvent; action.split('.').forEach(k => (investigate = investigate?.[k])); if (Number.isFinite(investigate)) requiredPower = investigate; let userPower = 0; if (Number.isFinite(powerLevelsEvent["users_default"])) userPower = powerLevelsEvent["users_default"]; if (Number.isFinite(powerLevelsEvent["users"]?.[userId])) userPower = powerLevelsEvent["users"][userId]; return userPower >= requiredPower; } /** * Determines the boundary conditions for this client's ability to change another user's power level * in a given room. This will identify the maximum possible level this client can change the user to, * and if that change could even be possible. If the returned object indicates that the client can * change the power level of the user, the client is able to set the power level to any value equal * to or less than the maximum value. * @param {string} targetUserId The user ID to compare against. * @param {string} roomId The room ID to compare within. * @returns {Promise} The bounds of the client's ability to change the user's power level. */ @timedMatrixClientFunctionCall() public async calculatePowerLevelChangeBoundsOn(targetUserId: string, roomId: string): Promise { const myUserId = await this.getUserId(); const canChangePower = await this.userHasPowerLevelFor(myUserId, roomId, "m.room.power_levels", true); if (!canChangePower) return { canModify: false, maximumPossibleLevel: 0 }; const powerLevelsEvent = await this.getRoomStateEvent(roomId, "m.room.power_levels", ""); if (!powerLevelsEvent) { throw new Error("No power level event found"); } let targetUserPower = 0; let myUserPower = 0; if (powerLevelsEvent["users"] && powerLevelsEvent["users"][targetUserId]) targetUserPower = powerLevelsEvent["users"][targetUserId]; if (powerLevelsEvent["users"] && powerLevelsEvent["users"][myUserId]) myUserPower = powerLevelsEvent["users"][myUserId]; if (myUserId === targetUserId) { return { canModify: true, maximumPossibleLevel: myUserPower }; } if (targetUserPower >= myUserPower) { return { canModify: false, maximumPossibleLevel: myUserPower }; } return { canModify: true, maximumPossibleLevel: myUserPower }; } /** * Sets the power level for a given user ID in the given room. Note that this is not safe to * call multiple times concurrently as changes are not atomic. This will throw an error if * the user lacks enough permission to change the power level, or if a power level event is * missing from the room. * @param {string} userId The user ID to change * @param {string} roomId The room ID to change the power level in * @param {number} newLevel The integer power level to set the user to. * @returns {Promise} Resolves when complete. */ @timedMatrixClientFunctionCall() public async setUserPowerLevel(userId: string, roomId: string, newLevel: number): Promise { const currentLevels = await this.getRoomStateEvent(roomId, "m.room.power_levels", ""); if (!currentLevels['users']) currentLevels['users'] = {}; currentLevels['users'][userId] = newLevel; await this.sendStateEvent(roomId, "m.room.power_levels", "", currentLevels); } /** * Converts a MXC URI to an HTTP URL. * @param {string} mxc The MXC URI to convert * @returns {string} The HTTP URL for the content. */ public mxcToHttp(mxc: string): string { if (!mxc.startsWith("mxc://")) throw new Error("Not a MXC URI"); const parts = mxc.substring("mxc://".length).split('/'); const originHomeserver = parts[0]; const mediaId = parts.slice(1, parts.length).join('/'); return `${this.homeserverUrl}/_matrix/media/v3/download/${encodeURIComponent(originHomeserver)}/${encodeURIComponent(mediaId)}`; } /** * Converts a MXC URI to an HTTP URL for downsizing the content. * @param {string} mxc The MXC URI to convert and downsize. * @param {number} width The width, as an integer, for the thumbnail. * @param {number} height The height, as an intenger, for the thumbnail. * @param {"crop"|"scale"} method Whether to crop or scale (preserve aspect ratio) the content. * @returns {string} The HTTP URL for the downsized content. */ public mxcToHttpThumbnail(mxc: string, width: number, height: number, method: "crop" | "scale"): string { const downloadUri = this.mxcToHttp(mxc); return downloadUri.replace("/_matrix/media/v3/download", "/_matrix/media/v3/thumbnail") + `?width=${width}&height=${height}&method=${encodeURIComponent(method)}`; } /** * Uploads data to the homeserver's media repository. Note that this will not automatically encrypt * media as it cannot determine if the media should be encrypted. * @param {Buffer} data the content to upload. * @param {string} contentType the content type of the file. Defaults to application/octet-stream * @param {string} filename the name of the file. Optional. * @returns {Promise} resolves to the MXC URI of the content */ @timedMatrixClientFunctionCall() public uploadContent(data: Buffer, contentType = "application/octet-stream", filename: string = null): Promise { // TODO: Make doRequest take an object for options return this.doRequest("POST", "/_matrix/media/v3/upload", { filename: filename }, data, 60000, false, contentType) .then(response => response["content_uri"]); } /** * Download content from the homeserver's media repository. Note that this will not automatically decrypt * media as it cannot determine if the media is encrypted. * @param {string} mxcUrl The MXC URI for the content. * @param {string} allowRemote Indicates to the server that it should not attempt to fetch the * media if it is deemed remote. This is to prevent routing loops where the server contacts itself. * Defaults to true if not provided. * @returns {Promise<{data: Buffer, contentType: string}>} Resolves to the downloaded content. */ public async downloadContent(mxcUrl: string, allowRemote = true): Promise<{ data: Buffer, contentType: string }> { if (!mxcUrl.toLowerCase().startsWith("mxc://")) { throw Error("'mxcUrl' does not begin with mxc://"); } const urlParts = mxcUrl.substring("mxc://".length).split("/"); const domain = encodeURIComponent(urlParts[0]); const mediaId = encodeURIComponent(urlParts[1].split("/")[0]); const path = `/_matrix/client/v1/media/download/${domain}/${mediaId}`; const res = await this.doRequest("GET", path, { allow_remote: allowRemote }, null, null, true, null, true); return { data: res.body, contentType: res.headers["content-type"], }; } /** * Uploads data to the homeserver's media repository after downloading it from the * provided URL. * @param {string} url The URL to download content from. * @returns {Promise} Resolves to the MXC URI of the content */ @timedMatrixClientFunctionCall() public uploadContentFromUrl(url: string): Promise { return new Promise<{ body: Buffer, contentType: string }>((resolve, reject) => { const requestId = ++this.requestId; const params = { uri: url, method: "GET", encoding: null, }; getRequestFn()(params, (err, response, resBody) => { if (err) { LogService.error("MatrixClientLite", "(REQ-" + requestId + ")", extractRequestError(err)); reject(err); } else { const contentType = response.headers['content-type'] || "application/octet-stream"; LogService.trace("MatrixClientLite", "(REQ-" + requestId + " RESP-H" + response.statusCode + ")", ""); if (response.statusCode < 200 || response.statusCode >= 300) { LogService.error("MatrixClientLite", "(REQ-" + requestId + ")", ""); reject(response); } else resolve({ body: resBody, contentType: contentType }); } }); }).then(obj => { return this.uploadContent(obj.body, obj.contentType); }); } /** * Determines the upgrade history for a given room as a doubly-linked list styled structure. Given * a room ID in the history of upgrades, the resulting `previous` array will hold any rooms which * are older than the given room. The resulting `newer` array will hold any rooms which are newer * versions of the room. Both arrays will be defined, but may be empty individually. Element zero * of each will always be the nearest to the given room ID and the last element will be the furthest * from the room. The given room will never be in either array. * @param {string} roomId the room ID to get the history of * @returns {Promise<{previous: RoomReference[], newer: RoomReference[]}>} Resolves to the room's * upgrade history */ @timedMatrixClientFunctionCall() public async getRoomUpgradeHistory(roomId: string): Promise<{ previous: RoomReference[], newer: RoomReference[], current: RoomReference }> { const result = { previous: [], newer: [], current: null }; const chaseCreates = async (findRoomId) => { try { const createEvent = await this.getRoomStateEvent(findRoomId, "m.room.create", ""); if (!createEvent) return; if (findRoomId === roomId && !result.current) { const version = createEvent['room_version'] || '1'; result.current = { roomId: roomId, version: version, refEventId: null, }; } if (createEvent['predecessor'] && createEvent['predecessor']['room_id']) { const prevRoomId = createEvent['predecessor']['room_id']; if (prevRoomId === findRoomId) return; // Recursion is bad if (result.previous.find(r => r.roomId === prevRoomId)) return; // Already found let tombstoneEventId = null; let prevVersion = "1"; try { const roomState = await this.getRoomState(prevRoomId); const tombstone = roomState.find(e => e['type'] === 'm.room.tombstone' && e['state_key'] === ''); const create = roomState.find(e => e['type'] === 'm.room.create' && e['state_key'] === ''); if (tombstone) { if (!tombstone['content']) tombstone['content'] = {}; const tombstoneRefRoomId = tombstone['content']['replacement_room']; if (tombstoneRefRoomId === findRoomId) tombstoneEventId = tombstone['event_id']; } if (create) { if (!create['content']) create['content'] = {}; prevVersion = create['content']['room_version'] || "1"; } } catch (e) { // state not available } result.previous.push({ roomId: prevRoomId, version: prevVersion, refEventId: tombstoneEventId, }); return chaseCreates(prevRoomId); } } catch (e) { // no create event - that's fine } }; const chaseTombstones = async (findRoomId) => { try { const tombstoneEvent = await this.getRoomStateEvent(findRoomId, "m.room.tombstone", ""); if (!tombstoneEvent) return; if (!tombstoneEvent['replacement_room']) return; const newRoomId = tombstoneEvent['replacement_room']; if (newRoomId === findRoomId) return; // Recursion is bad if (result.newer.find(r => r.roomId === newRoomId)) return; // Already found let newRoomVersion = "1"; let createEventId = null; try { const roomState = await this.getRoomState(newRoomId); const create = roomState.find(e => e['type'] === 'm.room.create' && e['state_key'] === ''); if (create) { if (!create['content']) create['content'] = {}; const predecessor = create['content']['predecessor'] || {}; const refPrevRoomId = predecessor['room_id']; if (refPrevRoomId === findRoomId) { createEventId = create['event_id']; } newRoomVersion = create['content']['room_version'] || "1"; } } catch (e) { // state not available } result.newer.push({ roomId: newRoomId, version: newRoomVersion, refEventId: createEventId, }); return await chaseTombstones(newRoomId); } catch (e) { // no tombstone - that's fine } }; await chaseCreates(roomId); await chaseTombstones(roomId); return result; } /** * Creates a Space room. * @param {SpaceCreateOptions} opts The creation options. * @returns {Promise} Resolves to the created space. */ @timedMatrixClientFunctionCall() public async createSpace(opts: SpaceCreateOptions): Promise { const roomCreateOpts: RoomCreateOptions = { name: opts.name, topic: opts.topic || "", preset: opts.isPublic ? "public_chat" : "private_chat", room_alias_name: opts.localpart, initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: opts.isPublic ? 'world_readable' : 'shared', }, }, ], creation_content: { type: "m.space", }, invite: opts.invites || [], power_level_content_override: { ban: 100, events_default: 50, invite: 50, kick: 100, notifications: { room: 100, }, redact: 100, state_default: 100, users: { [await this.getUserId()]: 100, }, users_default: 0, }, }; if (opts.avatarUrl) { roomCreateOpts.initial_state.push({ type: 'm.room.avatar', state_key: "", content: { url: opts.avatarUrl, }, }); } const roomId = await this.createRoom(roomCreateOpts); return new Space(roomId, this); } /** * Gets a Space. * This API does not work with unstable spaces (e.g. org.matrix.msc.1772.space) * * @throws If the room is not a space or there was an error * @returns {Promise} Resolves to the space. */ @timedMatrixClientFunctionCall() public async getSpace(roomIdOrAlias: string): Promise { const roomId = await this.resolveRoom(roomIdOrAlias); const createEvent = await this.getRoomStateEvent(roomId, "m.room.create", ""); if (createEvent["type"] !== "m.space") { throw new Error("Room is not a space"); } return new Space(roomId, this); } /** * Uploads One Time Keys for the current device. * @param {OTKs} keys The keys to upload. * @returns {Promise} Resolves to the current One Time Key counts when complete. */ @timedMatrixClientFunctionCall() @requiresCrypto() public async uploadDeviceOneTimeKeys(keys: OTKs): Promise { return this.doRequest("POST", "/_matrix/client/v3/keys/upload", null, { one_time_keys: keys, }).then(r => r['one_time_key_counts']); } /** * Gets the current One Time Key counts. * @returns {Promise} Resolves to the One Time Key counts. */ @timedMatrixClientFunctionCall() @requiresCrypto() public async checkOneTimeKeyCounts(): Promise { return this.doRequest("POST", "/_matrix/client/v3/keys/upload", null, {}) .then(r => r['one_time_key_counts']); } /** * Uploads a fallback One Time Key to the server for usage. This will replace the existing fallback * key. * @param {FallbackKey} fallbackKey The fallback key. * @returns {Promise} Resolves to the One Time Key counts. */ @timedMatrixClientFunctionCall() @requiresCrypto() public async uploadFallbackKey(fallbackKey: FallbackKey): Promise { const keyObj = { [`${OTKAlgorithm.Signed}:${fallbackKey.keyId}`]: fallbackKey.key, }; return this.doRequest("POST", "/_matrix/client/v3/keys/upload", null, { "org.matrix.msc2732.fallback_keys": keyObj, "fallback_keys": keyObj, }).then(r => r['one_time_key_counts']); } /** * Gets unverified device lists for the given users. The caller is expected to validate * and verify the device lists, including that the returned devices belong to the claimed users. * * Failures with federation are reported in the returned object. Users which did not fail a federation * lookup but have no devices will not appear in either the failures or in the returned devices. * * See https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-keys-query for more * information. * @param {string[]} userIds The user IDs to query. * @param {number} federationTimeoutMs The default timeout for requesting devices over federation. Defaults to * 10 seconds. * @returns {Promise} Resolves to the device list/errors for the requested user IDs. */ @timedMatrixClientFunctionCall() public async getUserDevices(userIds: string[], federationTimeoutMs = 10000): Promise { const req = {}; for (const userId of userIds) { req[userId] = []; } return this.doRequest("POST", "/_matrix/client/v3/keys/query", {}, { timeout: federationTimeoutMs, device_keys: req, }); } /** * Gets a device list for the client's own account, with metadata. The devices are not verified * in this response, but should be active on the account. * @returns {Promise} Resolves to the active devices on the account. */ @timedMatrixClientFunctionCall() public async getOwnDevices(): Promise { return this.doRequest("GET", "/_matrix/client/v3/devices").then(r => { return r['devices']; }); } /** * Claims One Time Keys for a set of user devices, returning those keys. The caller is expected to verify * and validate the returned keys. * * Failures with federation are reported in the returned object. * @param {Record>} userDeviceMap The map of user IDs to device IDs to * OTKAlgorithm to request a claim for. * @param {number} federationTimeoutMs The default timeout for claiming keys over federation. Defaults to * 10 seconds. */ @timedMatrixClientFunctionCall() @requiresCrypto() public async claimOneTimeKeys(userDeviceMap: Record>, federationTimeoutMs = 10000): Promise { return this.doRequest("POST", "/_matrix/client/v3/keys/claim", {}, { timeout: federationTimeoutMs, one_time_keys: userDeviceMap, }); } /** * Sends to-device messages to the respective users/devices. * @param {string} type The message type being sent. * @param {Record>} messages The messages to send, mapped as user ID to * device ID (or "*" to denote all of the user's devices) to message payload (content). * @returns {Promise} Resolves when complete. */ @timedMatrixClientFunctionCall() public async sendToDevices(type: string, messages: Record>): Promise { const txnId = (new Date().getTime()) + "_TDEV__inc" + (++this.requestId); return this.doRequest("PUT", `/_matrix/client/v3/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, { messages: messages, }); } /** * Get relations for a given event. * @param {string} roomId The room ID to for the given event. * @param {string} eventId The event ID to list relations for. * @param {string?} relationType The type of relations (e.g. `m.room.member`) to filter for. Optional. * @param {string?} eventType The type of event to look for (e.g. `m.room.member`). Optional. * @returns {Promise<{chunk: any[]}>} Resolves to an object containing the chunk of relations */ @timedMatrixClientFunctionCall() public async getRelationsForEvent(roomId: string, eventId: string, relationType?: string, eventType?: string): Promise<{ chunk: any[] }> { let url = `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}`; if (relationType) { url += `/${relationType}`; } if (eventType) { url += `/${eventType}`; } return this.doRequest("GET", url); } /** * Performs a web request to the homeserver, applying appropriate authorization headers for * this client. * @param {"GET"|"POST"|"PUT"|"DELETE"} method The HTTP method to use in the request * @param {string} endpoint The endpoint to call. For example: "/_matrix/client/v3/account/whoami" * @param {any} qs The query string to send. Optional. * @param {any} body The request body to send. Optional. Will be converted to JSON unless the type is a Buffer. * @param {number} timeout The number of milliseconds to wait before timing out. * @param {boolean} raw If true, the raw response will be returned instead of the response body. * @param {string} contentType The content type to send. Only used if the `body` is a Buffer. * @param {string} noEncoding Set to true to disable encoding, and return a Buffer. Defaults to false * @returns {Promise} Resolves to the response (body), rejected if a non-2xx status code was returned. */ @timedMatrixClientFunctionCall() public doRequest(method, endpoint, qs = null, body = null, timeout = 60000, raw = false, contentType = "application/json", noEncoding = false): Promise { if (this.impersonatedUserId) { if (!qs) qs = { "user_id": this.impersonatedUserId }; else qs["user_id"] = this.impersonatedUserId; } if (this.impersonatedDeviceId) { if (!qs) qs = { "org.matrix.msc3202.device_id": this.impersonatedDeviceId }; else qs["org.matrix.msc3202.device_id"] = this.impersonatedDeviceId; } const headers = {}; if (this.accessToken) { headers["Authorization"] = `Bearer ${this.accessToken}`; } return doHttpRequest(this.homeserverUrl, method, endpoint, qs, body, headers, timeout, raw, contentType, noEncoding); } } export interface RoomDirectoryLookupResponse { roomId: string; residentServers: string[]; } export interface RoomReference { /** * The room ID being referenced */ roomId: string; /** * The version of the room at the time */ version: string; /** * If going backwards, the tombstone event ID, otherwise the creation * event. If the room can't be verified, this will be null. Will be * null if this reference is to the current room. */ refEventId: string; } ================================================ FILE: src/PantalaimonClient.ts ================================================ /** * A client specifically designed to interact with Pantalaimon instead of * a Matrix homeserver. The key part of this is managing the access token * and username/password for interacting with Pantalaimon. * * If the storage provider given claims to have an access token for * this client, it will be used even if Pantalaimon considers it invalid. * * Expected usage: * * const storage = new SimpleFsStorageProvider("storage/bot.json"); * const pantalaimon = new PantalaimonClient("http://localhost:8008", storage); * * // Note that the credentials will only be used if there is no available access token. * const client = await pantalaimon.createClientWithCredentials("username", "password"); * */ import { IStorageProvider } from "./storage/IStorageProvider"; import { MatrixClient } from "./MatrixClient"; import { MatrixAuth } from "./MatrixAuth"; const ACCESS_TOKEN_STORAGE_KEY = "pantalaimon_access_token"; // TODO: Write a test for this (it's hard because of the many interactions with different parts) /** * Supporting functions for interacting with a Pantalaimon instance. * @category Encryption */ export class PantalaimonClient { /** * Creates a new PantalaimonClient class for interacting with Pantalaimon. The storage * provider given will also be used in the client. * @param {string} homeserverUrl The homeserver (Pantalaimon) URL to interact with. * @param {IStorageProvider} storageProvider The storage provider to back the client with. */ public constructor(private homeserverUrl: string, private storageProvider: IStorageProvider) { // nothing to do } /** * Authenticates and creates the Pantalaimon-capable client. The username and password given * are only used if the storage provider does not reveal an access token. * @param {string} username The username to log in with, if needed. * @param {string} password The password to log in with, if needed. * @returns {Promise} Resolves to a MatrixClient ready for interacting with Pantalaimon. */ public async createClientWithCredentials(username: string, password: string): Promise { const accessToken = await Promise.resolve(this.storageProvider.readValue(ACCESS_TOKEN_STORAGE_KEY)); if (accessToken) { return new MatrixClient(this.homeserverUrl, accessToken, this.storageProvider); } const auth = new MatrixAuth(this.homeserverUrl); const authedClient = await auth.passwordLogin(username, password); await Promise.resolve(this.storageProvider.storeValue(ACCESS_TOKEN_STORAGE_KEY, authedClient.accessToken)); // We recreate the client to ensure we set it up with the right storage provider. return new MatrixClient(this.homeserverUrl, authedClient.accessToken, this.storageProvider); } } ================================================ FILE: src/SynapseAdminApis.ts ================================================ import { MatrixClient } from "./MatrixClient"; import { MatrixError } from "./models/MatrixError"; /** * Information about a user on Synapse. * @category Admin APIs */ export interface SynapseUser { /*** * The display name of the user, if set. */ displayname?: string; /** * External IDs for the user. */ external_ids?: { auth_provider: string; external_id: string; }[]; /** * A set of 3PIDs for the user. */ threepids?: { medium: string; address: string; }[]; /** * The avatar URL (usually MXC URI) for the user, if set. */ avatar_url?: string; /** * Whether or not the user is a Synapse administrator. */ admin?: boolean; /** * Whether or not the user is deactivated. */ deactivated?: boolean; } /** * Added information to include when updating/creating a user. * @category Admin APIs */ export interface SynapseUserProperties extends SynapseUser { /** * The password for the user. Leave undefined to leave unchanged. */ password?: string; } /** * Information about a user on Synapse. * @category Admin APIs */ export interface SynapseUserListing { /** * User ID. */ name: string; /** * Whether or not the user is a guest. 1 is true, 0 is false. */ is_guest: number; /** * Whether or not the user is an admin. 1 is true, 0 is false. */ admin: number; /** * Whether or not the user is deactivated. 1 is true, 0 is false. */ deactivated: number; /** * The type of user, if relevant. */ user_type: string | null; /** * The hash of the user's password, if relevant. */ password_hash: string | null; /** * The display name of the user, if set. */ displayname: string | null; /** * The avatar for the user, if set. */ avatar_url: string | null; } /** * A resulting list of users on Synapse. * @category Admin APIs */ export interface SynapseUserList { /** * A set of users matching the criteria. */ users: SynapseUserListing[]; /** * The token to use to get the next set of users. */ next_token: string; /** * The total number of users on the Synapse instance. */ total: number; } /** * A registration token on Synapse * @category Admin APIs */ export interface SynapseRegistrationToken { token: string; uses_allowed: null | number; pending: number; completed: number; expiry_time: null | number; } export interface SynapseRegistrationTokenUpdateOptions { /** * The integer number of times the token can be used to complete a registration before it becomes invalid. * If null the token will have an unlimited number of uses. * Default: unlimited uses. */ uses_allowed?: number | null; /** * The latest time the token is valid. Given as the number of milliseconds since 1970-01-01 00:00:00 UTC (the start of the Unix epoch). * If null the token will not expire. * Default: token does not expire. */ expiry_time?: number | null; } export interface SynapseRegistrationTokenOptions extends SynapseRegistrationTokenUpdateOptions { /** * The registration token. A string of no more than 64 characters that consists only of characters matched by the regex [A-Za-z0-9._~-]. * Default: randomly generated. */ token?: string; /** * The length of the token randomly generated if token is not specified. Must be between 1 and 64 inclusive. * Default: 16. */ length?: number; } /** * Information about a room on Synapse. * @category Admin APIs */ export interface SynapseRoomListing { room_id: string; name: string; canonical_alias: string; joined_members: number; joined_local_members: number; version: string; creator: string; encryption: string; // algorithm federatable: boolean; public: boolean; join_rules: string; guest_access: string; history_visibility: string; state_events: number; } /** * A resulting list of rooms on Synapse. * @category Admin APIs */ export interface SynapseRoomList { rooms: SynapseRoomListing[]; offset: string; total_rooms: number; next_batch: string; prev_batch: string; } /** * Available properties on a Synapse room listing to order by. * @category Admin APIs */ export enum SynapseRoomProperty { Name = "name", CanonicalAlias = "canonical_alias", JoinedMembers = "joined_members", JoinedLocalMembers = "joined_local_members", Version = "version", Creator = "creator", Encryption = "encryption", CanFederate = "federatable", IsPublic = "public", JoinRules = "join_rules", GuestAccess = "guest_access", HistoryVisibility = "history_visibility", NumStateEvents = "state_events", } export interface SynapseListUserOptions { /** * Filters to only return users with user IDs that contain this value. This parameter is ignored when using the name parameter. */ user_id?: string; /** * Filters to only return users with user ID localparts or displaynames that contain this value. */ name?: string; /** * If false will exclude guest users. Defaults to true to include guest users. */ guests?: boolean; /** * If true will include deactivated users. Defaults to false to exclude deactivated users. */ deactivated?: boolean; /** * The method by which to sort the returned list of users. If the ordered field has duplicates, the * second order is always by ascending name, which guarantees a stable ordering. * **Caution**: The database only has indexes on the columns `name` and `creation_ts`. This means * that if a different sort order is used, it can cause a large load on the database. */ order_by?: "name" | "is_guest" | "admin" | "user_type" | "deactivated" | "shadow_banned" | "displayname" | "avatar_url" | "creation_ts"; /** * The number of results to return at a time. */ limit?: number; } /** * Access to various administrative APIs specifically available in Synapse. * @category Admin APIs */ export class SynapseAdminApis { constructor(private client: MatrixClient) { } /** * Get information about a user. The client making the request must be an admin user. * @param {string} userId The user ID to check. * @returns {Promise} The resulting Synapse user record */ public async getUser(userId: string): Promise { return this.client.doRequest( "GET", "/_synapse/admin/v2/users/" + encodeURIComponent(userId), ); } /** * Create or update a given user on a Synapse server. The * client making the request must be an admin user. * @param {string} userId The user ID to check. * @param {SynapseUserProperties} opts Options to set when creating or updating the user. * @returns {Promise} The resulting Synapse user record */ public async upsertUser(userId: string, opts: SynapseUserProperties = {}): Promise { return this.client.doRequest( "PUT", "/_synapse/admin/v2/users/" + encodeURIComponent(userId), undefined, opts, ); } /** * Get a list of users registered with Synapse, optionally filtered by some criteria. The * client making the request must be an admin user. * @param {string} from The token to continue listing users from. * @param {number} limit The maximum number of users to request. * @param {string} name Optional localpart or display name filter for results. * @param {boolean} guests Whether or not to include guest accounts. Default true. * @param {boolean} deactivated Whether or not to include deactivated accounts. Default false. * @returns {Promise} A batch of user results. */ public async listUsers(from?: string, limit?: number, name?: string, guests = true, deactivated = false): Promise { const qs = { guests, deactivated }; if (from) qs['from'] = from; if (limit) qs['limit'] = limit; if (name) qs['name'] = name; return this.client.doRequest("GET", "/_synapse/admin/v2/users", qs); } /** * Get a list of all users registered with Synapse, optionally filtered by some criteria. The * client making the request must be an admin user. * * This method returns an async generator that can be used to filter results. * @param options Options to pass to the user listing function * @example * for await (const user of synapseAdminApis.listAllUsers()) { * if (user.name === '@alice:example.com') { * return user; * } * } */ public async* listAllUsers(options: SynapseListUserOptions = {}): AsyncGenerator { let from: string | undefined = undefined; let response: SynapseUserList; do { const qs = { ...options, ...(from && { from }), }; response = await this.client.doRequest("GET", "/_synapse/admin/v2/users", qs); for (const user of response.users) { yield user; } from = response.next_token; } while (from); } /** * Determines if the given user is a Synapse server administrator for this homeserver. The * client making the request must be an admin user themselves (check with `isSelfAdmin`) * @param {string} userId The user ID to check. * @returns {Promise} Resolves to true if the user is an admin, false otherwise. * Throws if there's an error. */ public async isAdmin(userId: string): Promise { const response = await this.client.doRequest("GET", `/_synapse/admin/v1/users/${encodeURIComponent(userId)}/admin`); return response['admin'] || false; } /** * Determines if the current user is an admin for the Synapse homeserver. * @returns {Promise} Resolve to true if the user is an admin, false otherwise. * Throws if there's an error. */ public async isSelfAdmin(): Promise { try { return await this.isAdmin(await this.client.getUserId()); } catch (err) { if (err instanceof MatrixError && err.errcode === 'M_FORBIDDEN') { return false; } throw err; } } /** * Lists the rooms on the server. * @param {string} searchTerm A term to search for in the room names * @param {string} from A previous batch token to search from * @param {number} limit The maximum number of rooms to return * @param {SynapseRoomProperty} orderBy A property of rooms to order by * @param {boolean} reverseOrder True to reverse the orderBy direction. * @returns {Promise} Resolves to the server's rooms, ordered and filtered. */ public async listRooms(searchTerm?: string, from?: string, limit?: number, orderBy?: SynapseRoomProperty, reverseOrder = false): Promise { const params = {}; if (from) params['from'] = from; if (limit) params['limit'] = limit; if (searchTerm) params['search_term'] = searchTerm; if (orderBy) params['order_by'] = orderBy; if (reverseOrder) { params['dir'] = 'b'; } else { params['dir'] = 'f'; } return this.client.doRequest("GET", "/_synapse/admin/v1/rooms", params); } /** * Gets a list of state events in a room. * @param {string} roomId The room ID to get state for. * @returns {Promise} Resolves to the room's state events. */ public async getRoomState(roomId: string): Promise { const r = await this.client.doRequest("GET", `/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/state`); return r?.['state'] || []; } /** * Deletes a room from the server, purging all record of it. * @param {string} roomId The room to delete. * @returns {Promise} Resolves when complete. */ public async deleteRoom(roomId: string): Promise { return this.client.doRequest("DELETE", `/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}`, {}, { purge: true }); } /** * Gets the status of all active deletion tasks, and all those completed in the last 24h, for the given room_id. * @param {string} roomId The room ID to get deletion state for. * @returns {Promise} Resolves to the room's deletion status results. */ public async getDeleteRoomState(roomId: string): Promise { const r = await this.client.doRequest("GET", `/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}/delete_status`); return r?.['results'] || []; } /** * List all registration tokens on the homeserver. * @param valid If true, only valid tokens are returned. * If false, only tokens that have expired or have had all uses exhausted are returned. * If omitted, all tokens are returned regardless of validity. * @returns An array of registration tokens. */ public async listRegistrationTokens(valid?: boolean): Promise { const res = await this.client.doRequest("GET", `/_synapse/admin/v1/registration_tokens`, { valid }); return res.registration_tokens; } /** * Get details about a single token. * @param token The token to fetch. * @returns A registration tokens, or null if not found. */ public async getRegistrationToken(token: string): Promise { try { return await this.client.doRequest("GET", `/_synapse/admin/v1/registration_tokens/${encodeURIComponent(token)}`); } catch (e) { if (e?.statusCode === 404) { return null; } throw e; } } /** * Create a new registration token. * @param options Options to pass to the request. * @returns The newly created token. */ public async createRegistrationToken(options: SynapseRegistrationTokenOptions = {}): Promise { return this.client.doRequest("POST", `/_synapse/admin/v1/registration_tokens/new`, undefined, options); } /** * Update an existing registration token. * @param token The token to update. * @param options Options to pass to the request. * @returns The newly created token. */ public async updateRegistrationToken(token: string, options: SynapseRegistrationTokenUpdateOptions): Promise { return this.client.doRequest("PUT", `/_synapse/admin/v1/registration_tokens/${encodeURIComponent(token)}`, undefined, options); } /** * Delete a registration token * @param token The token to update. * @returns A promise that resolves upon success. */ public async deleteRegistrationToken(token: string): Promise { return this.client.doRequest("DELETE", `/_synapse/admin/v1/registration_tokens/${encodeURIComponent(token)}`, undefined, {}); } /** * Grants another user the highest power available to a local user who is in the room. * If the user is not in the room, and it is not publicly joinable, then invite the user. * @param roomId The room to make the user admin in. * @param userId The user to make admin in the room. If undefined, it uses the authenticated user. * @returns Resolves when complete. */ public async makeRoomAdmin(roomId: string, userId?: string): Promise { return this.client.doRequest("POST", `/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/make_room_admin`, {}, { user_id: userId }); } } ================================================ FILE: src/SynchronousMatrixClient.ts ================================================ import { MatrixClient } from "./MatrixClient"; /** * A MatrixClient class that handles events in sync for the /sync loop, instead * of trying to push its way through the /sync loop as fast as possible. It is * intended that the consumer extend this class and override the onWhatever() * functions it needs. All of the onWhatever() functions have a default behaviour * of doing nothing. */ export abstract class SynchronousMatrixClient extends MatrixClient { /** * Creates a new SynchronousMatrixClient. Note that this accepts a MatrixClient, though * much of the class's properties are not brought over. Always convert your MatrixClient * instance to a SynchronousMatrixClient as soon as possible to avoid diversion in which * properties are proxied over. * @param {MatrixClient} baseClient The client to wrap. */ protected constructor(baseClient: MatrixClient) { super(baseClient.homeserverUrl, baseClient.accessToken, baseClient.storageProvider); } private async handleEvent(emitType: string, arg1: any, arg2: any): Promise { if (emitType === 'account_data') await this.onAccountData(arg1); if (emitType === 'room.account_data') await this.onRoomAccountData(arg1, arg2); if (emitType === 'room.leave') await this.onRoomLeave(arg1, arg2); if (emitType === 'room.invite') await this.onRoomInvite(arg1, arg2); if (emitType === 'room.join') await this.onRoomJoin(arg1, arg2); if (emitType === 'room.archived') await this.onRoomArchived(arg1, arg2); if (emitType === 'room.upgraded') await this.onRoomUpgraded(arg1, arg2); if (emitType === 'room.message') await this.onRoomMessage(arg1, arg2); if (emitType === 'room.event') await this.onRoomEvent(arg1, arg2); // Still emit though for easier support of plugins. this.emit(emitType, arg1, arg2); } protected startSyncInternal(): Promise { return this.startSync(this.handleEvent.bind(this)); } /** * Handles the `account_data` event raised by the client. * @param {any} event The account data event. * @returns {Promise} Resolves when complete. */ protected onAccountData(event: any): Promise { return; } /** * Handles the `room.account_data` event raised by the client. * @param {string} roomId The Room ID the account data applies to. * @param {any} event The room account data event. * @returns {Promise} Resolves when complete. */ protected onRoomAccountData(roomId: string, event: any): Promise { return; } /** * Handles the `room.leave` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomLeave(roomId: string, event: any): Promise { return; } /** * Handles the `room.invite` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomInvite(roomId: string, event: any): Promise { return; } /** * Handles the `room.join` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomJoin(roomId: string, event: any): Promise { return; } /** * Handles the `room.message` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomMessage(roomId: string, event: any): Promise { return; } /** * Handles the `room.archived` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomArchived(roomId: string, event: any): Promise { return; } /** * Handles the `room.upgraded` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomUpgraded(roomId: string, event: any): Promise { return; } /** * Handles the `room.event` event raised by the client. * @param {string} roomId The Room ID the event happened in. * @param {any} event The event. * @returns {Promise} Resolves when complete. */ protected onRoomEvent(roomId: string, event: any): Promise { return; } } ================================================ FILE: src/UnstableApis.ts ================================================ import { MatrixClient } from "./MatrixClient"; import { MSC2380MediaInfo } from "./models/unstable/MediaInfo"; /** * Unstable APIs that shouldn't be used in most circumstances. * @category Unstable APIs */ export class UnstableApis { constructor(private client: MatrixClient) { } /** * Gets the local room aliases that are published for a given room. * @param {string} roomId The room ID to get local aliases for. * @returns {Promise} Resolves to the aliases on the room, or an empty array. * @deprecated Relies on MSC2432 endpoint. */ public async getRoomAliases(roomId: string): Promise { const r = await this.client.doRequest("GET", "/_matrix/client/unstable/org.matrix.msc2432/rooms/" + encodeURIComponent(roomId) + "/aliases"); return r['aliases'] || []; } /** * Adds a reaction to an event. The contract for this function may change in the future. * @param {string} roomId The room ID to react in * @param {string} eventId The event ID to react against, in the given room * @param {string} emoji The emoji to react with * @returns {Promise} Resolves to the event ID of the reaction */ public async addReactionToEvent(roomId: string, eventId: string, emoji: string): Promise { return this.client.sendRawEvent(roomId, "m.reaction", { "m.relates_to": { event_id: eventId, key: emoji, rel_type: "m.annotation", }, }); } /** * Get relations for a given event. * @param {string} roomId The room ID to for the given event. * @param {string} eventId The event ID to list relations for. * @param {string?} relationType The type of relations (e.g. `m.room.member`) to filter for. Optional. * @param {string?} eventType The type of event to look for (e.g. `m.room.member`). Optional. * @returns {Promise<{chunk: any[]}>} Resolves to an object containing the chunk of relations * @deprecated Please use the function of the same name in MatrixClient. This will be removed in a future release. */ public async getRelationsForEvent(roomId: string, eventId: string, relationType?: string, eventType?: string): Promise<{ chunk: any[] }> { let url = `/_matrix/client/unstable/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}`; if (relationType) { url += `/${relationType}`; } if (eventType) { url += `/${eventType}`; } return this.client.doRequest("GET", url); } /** * Get information about a media item. Implements MSC2380 * @param {string} mxcUrl The MXC to get information about. * @returns {Promise} Resolves to an object containing the media information. */ public async getMediaInfo(mxcUrl: string): Promise { if (!mxcUrl.toLowerCase().startsWith("mxc://")) { throw Error("'mxcUrl' does not begin with mxc://"); } const [domain, mediaId] = mxcUrl.substring("mxc://".length).split("/"); if (!domain || !mediaId) { throw Error('Missing domain or media ID'); } return this.client.doRequest("GET", `/_matrix/media/unstable/info/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`); } } ================================================ FILE: src/appservice/Appservice.ts ================================================ import * as express from "express"; import { EventEmitter } from "events"; import * as morgan from "morgan"; import * as LRU from "lru-cache"; import { stringify } from "querystring"; import { Intent } from "./Intent"; import { AppserviceJoinRoomStrategy, EncryptedRoomEvent, EventKind, IAppserviceCryptoStorageProvider, IAppserviceStorageProvider, IJoinRoomStrategy, IPreprocessor, LogService, MatrixClient, MemoryStorageProvider, Metrics, MSC3983KeyClaimResponse, MSC3984KeyQueryResponse, OTKAlgorithm, redactObjectForLogging, UserID, } from ".."; import { MatrixBridge } from "./MatrixBridge"; import { IApplicationServiceProtocol } from "./http_responses"; const EDU_ANNOTATION_KEY = "io.t2bot.sdk.bot.type"; enum EduAnnotation { ToDevice = "to_device", Ephemeral = "ephemeral", } /** * Represents an application service's registration file. This is expected to be * loaded from another source, such as a YAML file. * @category Application services */ export interface IAppserviceRegistration { /** * Optional ID for the appplication service. Used by homeservers to track which application * service registers what. */ id?: string; /** * Optional URL at which the application service can be contacted. */ url?: string; /** * The token the application service uses to communicate with the homeserver. */ as_token: string; /** * The token the homeserver uses to communicate with the application service. */ hs_token: string; /** * The application service's own localpart (eg: "_irc_bot" in the user ID "@_irc_bot:domain.com") */ sender_localpart: string; /** * The various namespaces the application service can support. */ namespaces: { /** * The user namespaces the application service is requesting. */ users: { /** * Whether or not the application service holds an exclusive lock on the namespace. This * means that no other user on the homeserver may register users that match this namespace. */ exclusive: boolean; /** * The regular expression that the homeserver uses to determine if a user is in this namespace. */ regex: string; }[]; /** * The room namespaces the application service is requesting. This is not for alises. */ rooms: { /** * Whether or not the application service holds an exclusive lock on the namespace. */ exclusive: boolean; /** * The regular expression that the homeserver uses to determine if a user is in this namespace. */ regex: string; }[]; /** * The room alias namespaces the application service is requesting. */ aliases: { /** * Whether or not the application service holds an exclusive lock on the namespace. This means * that no other user on the homeserver may register aliases that match this namespace. */ exclusive: boolean; /** * The regular expression that the homeserver uses to determine if an alias is in this namespace. */ regex: string; }[]; }; /** * The protocols the application service supports. Optional. */ protocols?: string[]; /** * If the application service is rate limited by the homeserver. Optional. */ rate_limited?: boolean; /** * **Experimental** * * Should the application service receive ephemeral events from the homeserver. Optional. * @see https://github.com/matrix-org/matrix-doc/pull/2409 */ "de.sorunome.msc2409.push_ephemeral"?: boolean; // not interested in other options } /** * General options for the application service * @category Application services */ export interface IAppserviceOptions { /** * The port to listen for requests from the homeserver on. */ port: number; /** * The bind address to listen for requests on. */ bindAddress: string; /** * The name of the homeserver, as presented over federation (eg: "matrix.org") */ homeserverName: string; /** * The URL to the homeserver's client server API (eg: "https://matrix.org") */ homeserverUrl: string; /** * The storage provider to use for this application service. */ storage?: IAppserviceStorageProvider; /** * The storage provider to use for setting up encryption. Encryption will be * disabled for all intents and the appservice if not configured. */ cryptoStorage?: IAppserviceCryptoStorageProvider; /** * The registration for this application service. */ registration: IAppserviceRegistration; /** * The join strategy to use for all intents, if any. */ joinStrategy?: IJoinRoomStrategy; /** * Options for how Intents are handled. */ intentOptions?: { /** * The maximum number of intents to keep cached. Defaults to 10 thousand. */ maxCached?: number; /** * The maximum age in milliseconds to keep an Intent around for, provided * the maximum number of intents has been reached. Defaults to 60 minutes. */ maxAgeMs?: number; /** * If false (default), crypto will not be automatically set up for all intent * instances - it will need to be manually enabled with * `await intent.enableEncryption()`. * * If true, crypto will be automatically set up. * * Note that the appservice bot account is considered an intent. */ encryption?: boolean; }; } /** * Represents an application service. This provides helper utilities such as tracking * of user intents (clients that are aware of their membership in rooms). * @category Application services */ export class Appservice extends EventEmitter { /** * The metrics instance for this appservice. This will raise all metrics * from this appservice instance as well as any intents/MatrixClients created * by the appservice. */ public readonly metrics: Metrics = new Metrics(); private readonly userPrefix: string | null; private readonly aliasPrefix: string | null; private readonly registration: IAppserviceRegistration; private readonly storage: IAppserviceStorageProvider; private readonly cryptoStorage: IAppserviceCryptoStorageProvider; private readonly bridgeInstance = new MatrixBridge(this); private app = express(); private appServer: any; private intentsCache: LRU.LRUCache; private eventProcessors: { [eventType: string]: IPreprocessor[] } = {}; private pendingTransactions: { [txnId: string]: Promise } = {}; /** * Creates a new application service. * @param {IAppserviceOptions} options The options for the application service. */ constructor(private options: IAppserviceOptions) { super(); options.joinStrategy = new AppserviceJoinRoomStrategy(options.joinStrategy, this); if (!options.intentOptions) options.intentOptions = {}; if (!options.intentOptions.maxAgeMs) options.intentOptions.maxAgeMs = 60 * 60 * 1000; if (!options.intentOptions.maxCached) options.intentOptions.maxCached = 10000; this.intentsCache = new LRU.LRUCache({ max: options.intentOptions.maxCached, ttl: options.intentOptions.maxAgeMs, }); this.registration = options.registration; // If protocol is not defined, define an empty array. if (!this.registration.protocols) { this.registration.protocols = []; } this.storage = options.storage || new MemoryStorageProvider(); options.storage = this.storage; this.cryptoStorage = options.cryptoStorage; this.app.use(express.json({ limit: Number.MAX_SAFE_INTEGER })); // disable limits, use a reverse proxy morgan.token('url-safe', (req: express.Request) => `${req.path}?${stringify(redactObjectForLogging(req.query ?? {}))}`, ); this.app.use(morgan( // Same as "combined", but with sensitive values removed from requests. ':remote-addr - :remote-user [:date[clf]] ":method :url-safe HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', { stream: { write: LogService.info.bind(LogService, 'Appservice') } }, )); // ETag headers break the tests sometimes, and we don't actually need them anyways for // appservices - none of this should be cached. this.app.set('etag', false); this.app.get("/users/:userId", this.onUser.bind(this)); this.app.get("/rooms/:roomAlias", this.onRoomAlias.bind(this)); this.app.put("/transactions/:txnId", this.onTransaction.bind(this)); this.app.get("/_matrix/app/v1/users/:userId", this.onUser.bind(this)); this.app.get("/_matrix/app/v1/rooms/:roomAlias", this.onRoomAlias.bind(this)); this.app.put("/_matrix/app/v1/transactions/:txnId", this.onTransaction.bind(this)); this.app.get("/_matrix/app/v1/thirdparty/protocol/:protocol", this.onThirdpartyProtocol.bind(this)); this.app.get("/_matrix/app/v1/thirdparty/user/:protocol", this.onThirdpartyUser.bind(this)); this.app.get("/_matrix/app/v1/thirdparty/user", this.onThirdpartyUser.bind(this)); this.app.get("/_matrix/app/v1/thirdparty/location/:protocol", this.onThirdpartyLocation.bind(this)); this.app.get("/_matrix/app/v1/thirdparty/location", this.onThirdpartyLocation.bind(this)); this.app.post("/_matrix/app/unstable/org.matrix.msc3983/keys/claim", this.onKeysClaim.bind(this)); this.app.post("/_matrix/app/unstable/org.matrix.msc3984/keys/query", this.onKeysQuery.bind(this)); // Workaround for https://github.com/matrix-org/synapse/issues/3780 this.app.post("/_matrix/app/v1/unstable/org.matrix.msc3983/keys/claim", this.onKeysClaim.bind(this)); this.app.post("/unstable/org.matrix.msc3983/keys/claim", this.onKeysClaim.bind(this)); this.app.post("/_matrix/app/v1/unstable/org.matrix.msc3984/keys/query", this.onKeysQuery.bind(this)); this.app.post("/unstable/org.matrix.msc3984/keys/query", this.onKeysQuery.bind(this)); // We register the 404 handler in the `begin()` function to allow consumers to add their own endpoints. if (!this.registration.namespaces || !this.registration.namespaces.users || this.registration.namespaces.users.length === 0) { throw new Error("No user namespaces in registration"); } if (this.registration.namespaces.users.length !== 1) { throw new Error("Too many user namespaces registered: expecting exactly one"); } const userPrefix = (this.registration.namespaces.users[0].regex || "").split(":")[0]; if (!userPrefix.endsWith(".*") && !userPrefix.endsWith(".+")) { this.userPrefix = null; } else { this.userPrefix = userPrefix.substring(0, userPrefix.length - 2); // trim off the .* part } if (!this.registration.namespaces?.aliases || this.registration.namespaces.aliases.length !== 1) { this.aliasPrefix = null; } else { this.aliasPrefix = (this.registration.namespaces.aliases[0].regex || "").split(":")[0]; if (!this.aliasPrefix.endsWith(".*") && !this.aliasPrefix.endsWith(".+")) { this.aliasPrefix = null; } else { this.aliasPrefix = this.aliasPrefix.substring(0, this.aliasPrefix.length - 2); // trim off the .* part } } } /** * Gets the express app instance which is serving requests. Not recommended for * general usage, but may be used to append routes to the web server. */ public get expressAppInstance() { return this.app; } /** * Gets the bridge-specific APIs for this application service. */ public get bridge(): MatrixBridge { return this.bridgeInstance; } /** * Get the application service's "bot" user ID (the sender_localpart). */ public get botUserId(): string { return this.getUserId(this.registration.sender_localpart); } /** * Get the application service's "bot" Intent (the sender_localpart). * @returns {Intent} The intent for the application service itself. */ public get botIntent(): Intent { return this.getIntentForUserId(this.botUserId); } /** * Get the application service's "bot" MatrixClient (the sender_localpart). * Normally the botIntent should be used to ensure that the bot user is safely * handled. * @returns {MatrixClient} The client for the application service itself. */ public get botClient(): MatrixClient { return this.botIntent.underlyingClient; } /** * Starts the application service, opening the bind address to begin processing requests. * @returns {Promise} resolves when started */ public begin(): Promise { return new Promise((resolve, reject) => { // Per constructor, all other endpoints should 404. // Technically, according to https://spec.matrix.org/v1.6/application-service-api/#unknown-routes we should // be returning 405 for *known* endpoints with the wrong method. this.app.all("*", (req: express.Request, res: express.Response) => { res.status(404).json({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" }); }); this.appServer = this.app.listen(this.options.port, this.options.bindAddress, () => resolve()); }).then(async () => { if (this.options.intentOptions?.encryption) { await this.botIntent.enableEncryption(); } else { await this.botIntent.ensureRegistered(); } }); } /** * Stops the application service, freeing the web server. */ public stop(): void { if (!this.appServer) return; this.appServer.close(); } /** * Gets an intent for a given localpart. The user ID will be formed with the domain name given * in the constructor. * @param localpart The localpart to get an Intent for. * @returns {Intent} An Intent for the user. */ public getIntent(localpart: string): Intent { return this.getIntentForUserId(this.getUserId(localpart)); } /** * Gets a full user ID for a given localpart. The user ID will be formed with the domain name given * in the constructor. * @param localpart The localpart to get a user ID for. * @returns {string} The user's ID. */ public getUserId(localpart: string): string { return `@${localpart}:${this.options.homeserverName}`; } /** * Gets an Intent for a given user suffix. The prefix is automatically detected from the registration * options. * @param suffix The user's suffix * @returns {Intent} An Intent for the user. */ public getIntentForSuffix(suffix: string): Intent { return this.getIntentForUserId(this.getUserIdForSuffix(suffix)); } /** * Gets a full user ID for a given suffix. The prefix is automatically detected from the registration * options. * @param suffix The user's suffix * @returns {string} The user's ID. */ public getUserIdForSuffix(suffix: string): string { if (!this.userPrefix) { throw new Error(`Cannot use getUserIdForSuffix, provided namespace did not include a valid suffix`); } return `${this.userPrefix}${suffix}:${this.options.homeserverName}`; } /** * Gets an Intent for a given user ID. * @param {string} userId The user ID to get an Intent for. * @returns {Intent} An Intent for the user. */ public getIntentForUserId(userId: string): Intent { let intent: Intent = this.intentsCache.get(userId); if (!intent) { intent = new Intent(this.options, userId, this); this.intentsCache.set(userId, intent); if (this.options.intentOptions.encryption) { intent.enableEncryption().catch(e => { LogService.error("Appservice", `Failed to set up crypto on intent ${userId}`, e); throw e; // re-throw to cause unhandled exception }); } } return intent; } /** * Gets the suffix for the provided user ID. If the user ID is not a namespaced * user, this will return a falsey value. * @param {string} userId The user ID to parse * @returns {string} The suffix from the user ID. */ public getSuffixForUserId(userId: string): string { if (!this.userPrefix) { throw new Error(`Cannot use getUserIdForSuffix, provided namespace did not include a valid suffix`); } if (!userId || !userId.startsWith(this.userPrefix) || !userId.endsWith(`:${this.options.homeserverName}`)) { // Invalid ID return null; } return userId .split('') .slice(this.userPrefix.length) .reverse() .slice(this.options.homeserverName.length + 1) .reverse() .join(''); } /** * Determines if a given user ID is namespaced by this application service. * @param {string} userId The user ID to check * @returns {boolean} true if the user is namespaced, false otherwise */ public isNamespacedUser(userId: string): boolean { return userId === this.botUserId || !!this.registration.namespaces?.users.find(({ regex }) => new RegExp(regex).test(userId), ); } /** * Gets a full alias for a given localpart. The alias will be formed with the domain name given * in the constructor. * @param localpart The localpart to get an alias for. * @returns {string} The alias. */ public getAlias(localpart: string): string { return `#${localpart}:${this.options.homeserverName}`; } /** * Gets a full alias for a given suffix. The prefix is automatically detected from the registration * options. * @param suffix The alias's suffix * @returns {string} The alias. */ public getAliasForSuffix(suffix: string): string { if (!this.aliasPrefix) { throw new Error("Invalid configured alias prefix"); } return `${this.aliasPrefix}${suffix}:${this.options.homeserverName}`; } /** * Gets the localpart of an alias for a given suffix. The prefix is automatically detected from the registration * options. Useful for the createRoom endpoint. * @param suffix The alias's suffix * @returns {string} The alias localpart. */ public getAliasLocalpartForSuffix(suffix: string): string { if (!this.aliasPrefix) { throw new Error("Invalid configured alias prefix"); } return `${this.aliasPrefix.substr(1)}${suffix}`; } /** * Gets the suffix for the provided alias. If the alias is not a namespaced * alias, this will return a falsey value. * @param {string} alias The alias to parse * @returns {string} The suffix from the alias. */ public getSuffixForAlias(alias: string): string { if (!this.aliasPrefix) { throw new Error("Invalid configured alias prefix"); } if (!alias || !this.isNamespacedAlias(alias)) { // Invalid ID return null; } return alias .split('') .slice(this.aliasPrefix.length) .reverse() .slice(this.options.homeserverName.length + 1) .reverse() .join(''); } /** * Determines if a given alias is namespaced by this application service. * @param {string} alias The alias to check * @returns {boolean} true if the alias is namespaced, false otherwise */ public isNamespacedAlias(alias: string): boolean { if (!this.aliasPrefix) { throw new Error("Invalid configured alias prefix"); } return alias.startsWith(this.aliasPrefix) && alias.endsWith(":" + this.options.homeserverName); } /** * Adds a preprocessor to the event pipeline. When this appservice encounters an event, it * will try to run it through the preprocessors it can in the order they were added. * @param {IPreprocessor} preprocessor the preprocessor to add */ public addPreprocessor(preprocessor: IPreprocessor): void { if (!preprocessor) throw new Error("Preprocessor cannot be null"); const eventTypes = preprocessor.getSupportedEventTypes(); if (!eventTypes) return; // Nothing to do for (const eventType of eventTypes) { if (!this.eventProcessors[eventType]) this.eventProcessors[eventType] = []; this.eventProcessors[eventType].push(preprocessor); } } /** * Sets the visibility of a room in the appservice's room directory. * @param {string} networkId The network ID to group the room under. * @param {string} roomId The room ID to manipulate the visibility of. * @param {"public" | "private"} visibility The visibility to set for the room. * @return {Promise} resolves when the visibility has been updated. */ public setRoomDirectoryVisibility(networkId: string, roomId: string, visibility: "public" | "private") { roomId = encodeURIComponent(roomId); networkId = encodeURIComponent(networkId); return this.botClient.doRequest("PUT", `/_matrix/client/v3/directory/list/appservice/${networkId}/${roomId}`, null, { visibility, }); } private async processEphemeralEvent(event: any): Promise { if (!event) return event; if (!this.eventProcessors[event["type"]]) return event; for (const processor of this.eventProcessors[event["type"]]) { await processor.processEvent(event, this.botIntent.underlyingClient, EventKind.EphemeralEvent); } return event; } private async processEvent(event: any): Promise { if (!event) return event; if (!this.eventProcessors[event["type"]]) return event; for (const processor of this.eventProcessors[event["type"]]) { await processor.processEvent(event, this.botIntent.underlyingClient, EventKind.RoomEvent); } return event; } private async processMembershipEvent(event: any): Promise { if (!event["content"]) return; const domain = new UserID(event['state_key']).domain; const botDomain = new UserID(this.botUserId).domain; if (domain !== botDomain) return; // can't be impersonated, so don't try // Update the target intent's joined rooms (fixes transition errors with the cache, like join->kick->join) const intent = this.getIntentForUserId(event['state_key']); await intent.refreshJoinedRooms(); const targetMembership = event["content"]["membership"]; if (targetMembership === "join") { this.emit("room.join", event["room_id"], event); await intent.underlyingClient.crypto?.onRoomJoin(event["room_id"]); } else if (targetMembership === "ban" || targetMembership === "leave") { this.emit("room.leave", event["room_id"], event); } else if (targetMembership === "invite") { this.emit("room.invite", event["room_id"], event); } } private isAuthed(req: any): boolean { let providedToken = req.query ? req.query["access_token"] : null; if (req.headers && req.headers["authorization"]) { const authHeader = req.headers["authorization"]; if (!authHeader.startsWith("Bearer ")) providedToken = null; else providedToken = authHeader.substring("Bearer ".length); } return providedToken === this.registration.hs_token; } private async onTransaction(req: express.Request, res: express.Response): Promise { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } if (typeof (req.body) !== "object") { res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" }); return; } if (!req.body["events"] || !Array.isArray(req.body["events"])) { res.status(400).json({ errcode: "BAD_REQUEST", error: "Invalid JSON: expected events" }); return; } const txnId = req.params["txnId"]; if (await Promise.resolve(this.storage.isTransactionCompleted(txnId))) { res.status(200).json({}); return; } if (this.pendingTransactions[txnId]) { try { await this.pendingTransactions[txnId]; res.status(200).json({}); } catch (e) { LogService.error("Appservice", e); res.status(500).json({}); } return; } LogService.info("Appservice", "Processing transaction " + txnId); // eslint-disable-next-line no-async-promise-executor this.pendingTransactions[txnId] = new Promise(async (resolve) => { // Process all the crypto stuff first to ensure that future transactions (if not this one) // will decrypt successfully. We start with EDUs because we need structures to put counts // and such into in a later stage, and EDUs are independent of crypto. const byUserId: { [userId: string]: { counts: Record; toDevice: any[]; unusedFallbacks: OTKAlgorithm[]; }; } = {}; const orderedEdus = []; if (Array.isArray(req.body["de.sorunome.msc2409.to_device"])) { orderedEdus.push(...req.body["de.sorunome.msc2409.to_device"].map(e => ({ ...e, unsigned: { ...e['unsigned'], [EDU_ANNOTATION_KEY]: EduAnnotation.ToDevice, }, }))); } if (Array.isArray(req.body["de.sorunome.msc2409.ephemeral"])) { orderedEdus.push(...req.body["de.sorunome.msc2409.ephemeral"].map(e => ({ ...e, unsigned: { ...e['unsigned'], [EDU_ANNOTATION_KEY]: EduAnnotation.Ephemeral, }, }))); } for (let event of orderedEdus) { if (event['edu_type']) event['type'] = event['edu_type']; // handle property change during MSC2409's course LogService.info("Appservice", `Processing ${event['unsigned'][EDU_ANNOTATION_KEY]} event of type ${event["type"]}`); event = await this.processEphemeralEvent(event); // These events aren't tied to rooms, so just emit them generically this.emit("ephemeral.event", event); if (this.cryptoStorage && (event["type"] === "m.room.encrypted" || event.unsigned?.[EDU_ANNOTATION_KEY] === EduAnnotation.ToDevice)) { const toUser = event["to_user_id"]; const intent = this.getIntentForUserId(toUser); await intent.enableEncryption(); if (!byUserId[toUser]) byUserId[toUser] = { counts: null, toDevice: null, unusedFallbacks: null }; if (!byUserId[toUser].toDevice) byUserId[toUser].toDevice = []; byUserId[toUser].toDevice.push(event); } } const deviceLists: { changed: string[], removed: string[] } = req.body["org.matrix.msc3202.device_lists"] ?? { changed: [], removed: [], }; if (!deviceLists.changed) deviceLists.changed = []; if (!deviceLists.removed) deviceLists.removed = []; if (deviceLists.changed.length || deviceLists.removed.length) { this.emit("device_lists", deviceLists); } let otks = req.body["org.matrix.msc3202.device_one_time_keys_count"]; const otks2 = req.body["org.matrix.msc3202.device_one_time_key_counts"]; if (otks2 && !otks) { LogService.warn( "Appservice", "Your homeserver is using an outdated field (device_one_time_key_counts) to talk to this appservice. " + "If you're using Synapse, please upgrade to 1.73.0 or higher.", ); otks = otks2; } if (otks) { this.emit("otk.counts", otks); } if (otks && this.cryptoStorage) { for (const userId of Object.keys(otks)) { const intent = this.getIntentForUserId(userId); await intent.enableEncryption(); const otksForUser = otks[userId][intent.underlyingClient.crypto.clientDeviceId]; if (otksForUser) { if (!byUserId[userId]) { byUserId[userId] = { counts: null, toDevice: null, unusedFallbacks: null, }; } byUserId[userId].counts = otksForUser; } } } const fallbacks = req.body["org.matrix.msc3202.device_unused_fallback_key_types"]; if (fallbacks) { this.emit("otk.unused_fallback_keys", fallbacks); } if (fallbacks && this.cryptoStorage) { for (const userId of Object.keys(fallbacks)) { const intent = this.getIntentForUserId(userId); await intent.enableEncryption(); const fallbacksForUser = fallbacks[userId][intent.underlyingClient.crypto.clientDeviceId]; if (Array.isArray(fallbacksForUser) && !fallbacksForUser.includes(OTKAlgorithm.Signed)) { if (!byUserId[userId]) { byUserId[userId] = { counts: null, toDevice: null, unusedFallbacks: null, }; } byUserId[userId].unusedFallbacks = fallbacksForUser; } } } if (this.cryptoStorage) { for (const userId of Object.keys(byUserId)) { const intent = this.getIntentForUserId(userId); await intent.enableEncryption(); const info = byUserId[userId]; const userStorage = this.storage.storageForUser(userId); if (!info.toDevice) info.toDevice = []; if (!info.unusedFallbacks) info.unusedFallbacks = JSON.parse(await userStorage.readValue("last_unused_fallbacks") || "[]"); if (!info.counts) info.counts = JSON.parse(await userStorage.readValue("last_counts") || "{}"); LogService.info("Appservice", `Updating crypto state for ${userId}`); await intent.underlyingClient.crypto.updateSyncData(info.toDevice, info.counts, info.unusedFallbacks, deviceLists.changed, deviceLists.removed); } } for (let event of req.body["events"]) { LogService.info("Appservice", `Processing event of type ${event["type"]}`); event = await this.processEvent(event); if (event['type'] === 'm.room.encrypted') { this.emit("room.encrypted_event", event["room_id"], event); if (this.cryptoStorage) { try { const encrypted = new EncryptedRoomEvent(event); const roomId = event['room_id']; try { event = (await this.botClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; event = await this.processEvent(event); this.emit("room.decrypted_event", roomId, event); // For logging purposes: show that the event was decrypted LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); } catch (e1) { LogService.warn("Appservice", `Bot client was not able to decrypt ${roomId} ${event['event_id']} - trying other intents`); let tryUserId: string; try { // TODO: This could be more efficient const userIdsInRoom = await this.botClient.getJoinedRoomMembers(roomId); tryUserId = userIdsInRoom.find(u => this.isNamespacedUser(u)); } catch (e) { LogService.error("Appservice", "Failed to get members of room - cannot decrypt message"); } if (tryUserId) { const intent = this.getIntentForUserId(tryUserId); event = (await intent.underlyingClient.crypto.decryptRoomEvent(encrypted, roomId)).raw; event = await this.processEvent(event); this.emit("room.decrypted_event", roomId, event); // For logging purposes: show that the event was decrypted LogService.info("Appservice", `Processing decrypted event of type ${event["type"]}`); } else { // noinspection ExceptionCaughtLocallyJS throw e1; } } } catch (e) { LogService.error("Appservice", `Decryption error on ${event['room_id']} ${event['event_id']}`, e); this.emit("room.failed_decryption", event['room_id'], event, e); } } } this.emit("room.event", event["room_id"], event); if (event['type'] === 'm.room.message') { this.emit("room.message", event["room_id"], event); } if (event['type'] === 'm.room.member' && this.isNamespacedUser(event['state_key'])) { await this.processMembershipEvent(event); } if (event['type'] === 'm.room.tombstone' && event['state_key'] === '') { this.emit("room.archived", event['room_id'], event); } if (event['type'] === 'm.room.create' && event['state_key'] === '' && event['content'] && event['content']['predecessor']) { this.emit("room.upgraded", event['room_id'], event); } } resolve(); }); try { await this.pendingTransactions[txnId]; await Promise.resolve(this.storage.setTransactionCompleted(txnId)); res.status(200).json({}); } catch (e) { LogService.error("Appservice", e); res.status(500).json({}); } } private async onUser(req: express.Request, res: express.Response): Promise { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } const userId = req.params["userId"]; this.emit("query.user", userId, async (result) => { if (result.then) result = await result; if (result === false) { res.status(404).json({ errcode: "USER_DOES_NOT_EXIST", error: "User not created" }); } else { const intent = this.getIntentForUserId(userId); await intent.ensureRegistered(); if (result.display_name) await intent.underlyingClient.setDisplayName(result.display_name); if (result.avatar_mxc) await intent.underlyingClient.setAvatarUrl(result.avatar_mxc); res.status(200).json(result); // return result for debugging + testing } }); } private async onRoomAlias(req: express.Request, res: express.Response): Promise { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } const roomAlias = req.params["roomAlias"]; this.emit("query.room", roomAlias, async (result) => { if (result.then) result = await result; if (result === false) { res.status(404).json({ errcode: "ROOM_DOES_NOT_EXIST", error: "Room not created" }); } else { const intent = this.botIntent; await intent.ensureRegistered(); result["room_alias_name"] = roomAlias.substring(1).split(':')[0]; result["__roomId"] = await intent.underlyingClient.createRoom(result); res.status(200).json(result); // return result for debugging + testing } }); } private async onKeysClaim(req: express.Request, res: express.Response): Promise { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } if (typeof (req.body) !== "object") { res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" }); return; } let responded = false; this.emit("query.key_claim", req.body, (result: MSC3983KeyClaimResponse | Promise | undefined | null) => { responded = true; const handleResult = (result2: MSC3983KeyClaimResponse) => { if (!result2) { res.status(404).json({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" }); return; } res.status(200).json(result2); }; Promise.resolve(result).then(r => handleResult(r)).catch(e => { LogService.error("Appservice", "Error handling key claim API", e); res.status(500).json({ errcode: "M_UNKNOWN", error: "Error handling key claim API" }); }); }); if (!responded) { res.status(404).json({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" }); } } private async onKeysQuery(req: express.Request, res: express.Response): Promise { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } if (typeof (req.body) !== "object") { res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" }); return; } let responded = false; this.emit("query.key", req.body, (result: MSC3984KeyQueryResponse | Promise | undefined | null) => { responded = true; const handleResult = (result2: MSC3984KeyQueryResponse) => { if (!result2) { res.status(404).json({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" }); return; } // Implementation note: we could probably query the device keys from our storage if we wanted to. res.status(200).json(result2); }; Promise.resolve(result).then(r => handleResult(r)).catch(e => { LogService.error("Appservice", "Error handling key query API", e); res.status(500).json({ errcode: "M_UNKNOWN", error: "Error handling key query API" }); }); }); if (!responded) { res.status(404).json({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" }); } } private onThirdpartyProtocol(req: express.Request, res: express.Response) { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } const protocol = req.params["protocol"]; if (!this.registration.protocols.includes(protocol)) { res.status(404).json({ errcode: "PROTOCOL_NOT_HANDLED", error: "Protocol is not handled by this appservice", }); return; } this.emit("thirdparty.protocol", protocol, (protocolResponse: IApplicationServiceProtocol) => { res.status(200).json(protocolResponse); }); } private handleThirdpartyObject(req: express.Request, res: express.Response, objType: string, matrixId?: string) { if (!this.isAuthed(req)) { res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" }); return; } const protocol = req.params["protocol"]; const responseFunc = (items: any[]) => { if (items && items.length > 0) { res.status(200).json(items); return; } res.status(404).json({ errcode: "NO_MAPPING_FOUND", error: "No mappings found", }); }; // Lookup remote objects(s) if (protocol) { // If protocol is given, we are looking up a objects based on fields if (!this.registration.protocols.includes(protocol)) { res.status(404).json({ errcode: "PROTOCOL_NOT_HANDLED", error: "Protocol is not handled by this appservice", }); return; } // Remove the access_token delete req.query.access_token; this.emit(`thirdparty.${objType}.remote`, protocol, req.query, responseFunc); return; } else if (matrixId) { // If a user ID is given, we are looking up a remote objects based on a id this.emit(`thirdparty.${objType}.matrix`, matrixId, responseFunc); return; } res.status(400).json({ errcode: "INVALID_PARAMETERS", error: "Invalid parameters given", }); } private onThirdpartyUser(req: express.Request, res: express.Response) { return this.handleThirdpartyObject(req, res, "user", req.query["userid"] as string); } private onThirdpartyLocation(req: express.Request, res: express.Response) { return this.handleThirdpartyObject(req, res, "location", req.query["alias"] as string); } } ================================================ FILE: src/appservice/Intent.ts ================================================ import { DeviceKeyAlgorithm, extractRequestError, IAppserviceCryptoStorageProvider, IAppserviceStorageProvider, ICryptoStorageProvider, LogService, MatrixClient, Metrics, } from ".."; import { Appservice, IAppserviceOptions } from "./Appservice"; // noinspection TypeScriptPreferShortImport import { timedIntentFunctionCall } from "../metrics/decorators"; import { UnstableAppserviceApis } from "./UnstableAppserviceApis"; import { MatrixError } from "../models/MatrixError"; /** * An Intent is an intelligent client that tracks things like the user's membership * in rooms to ensure the action being performed is possible. This is very similar * to how Intents work in the matrix-js-sdk in that the Intent will ensure that the * user is joined to the room before posting a message, for example. * @category Application services */ export class Intent { /** * The metrics instance for this intent. Note that this will not raise metrics * for the underlying client - those will be available through this instance's * parent (the appservice). */ public readonly metrics: Metrics; private readonly storage: IAppserviceStorageProvider; private readonly cryptoStorage: IAppserviceCryptoStorageProvider; private client: MatrixClient; private unstableApisInstance: UnstableAppserviceApis; private knownJoinedRooms: string[] = []; private cryptoSetupPromise: Promise; /** * Creates a new intent. Intended to be created by application services. * @param {IAppserviceOptions} options The options for the application service. * @param {string} impersonateUserId The user ID to impersonate. * @param {Appservice} appservice The application service itself. */ constructor(private options: IAppserviceOptions, private impersonateUserId: string, private appservice: Appservice) { this.metrics = new Metrics(appservice.metrics); this.storage = options.storage; this.cryptoStorage = options.cryptoStorage; this.makeClient(false); } private makeClient(withCrypto: boolean, accessToken?: string) { let cryptoStore: ICryptoStorageProvider; const storage = this.storage?.storageForUser?.(this.userId); if (withCrypto) { cryptoStore = this.cryptoStorage?.storageForUser(this.userId); if (!cryptoStore) { throw new Error("Tried to set up client with crypto when not available"); } if (!storage) { throw new Error("Tried to set up client with crypto, but no persistent storage"); } } this.client = new MatrixClient(this.options.homeserverUrl, accessToken ?? this.options.registration.as_token, storage, cryptoStore); this.client.metrics = new Metrics(this.appservice.metrics); // Metrics only go up by one parent this.unstableApisInstance = new UnstableAppserviceApis(this.client); if (this.impersonateUserId !== this.appservice.botUserId) { this.client.impersonateUserId(this.impersonateUserId); } if (this.options.joinStrategy) { this.client.setJoinStrategy(this.options.joinStrategy); } } /** * Gets the user ID this intent is for. */ public get userId(): string { return this.impersonateUserId; } /** * Gets the underlying MatrixClient that powers this Intent. */ public get underlyingClient(): MatrixClient { return this.client; } /** * Gets the unstable API access class. This is generally not recommended to be * used by appservices. * @return {UnstableAppserviceApis} The unstable API access class. */ public get unstableApis(): UnstableAppserviceApis { return this.unstableApisInstance; } /** * Sets up crypto on the client if it hasn't already been set up. * @returns {Promise} Resolves when complete. */ @timedIntentFunctionCall() public async enableEncryption(): Promise { if (!this.cryptoSetupPromise) { // eslint-disable-next-line no-async-promise-executor this.cryptoSetupPromise = new Promise(async (resolve, reject) => { try { // Prepare a client first await this.ensureRegistered(); const storage = this.storage?.storageForUser?.(this.userId); this.client.impersonateUserId(this.userId); // make sure the devices call works const cryptoStore = this.cryptoStorage?.storageForUser(this.userId); if (!cryptoStore) { // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to create crypto store"); } // Try to impersonate a device ID const ownDevices = await this.client.getOwnDevices(); let deviceId = await cryptoStore.getDeviceId(); if (!deviceId || !ownDevices.some(d => d.device_id === deviceId)) { const deviceKeys = await this.client.getUserDevices([this.userId]); const userDeviceKeys = deviceKeys.device_keys[this.userId]; if (userDeviceKeys) { // We really should be validating signatures here, but we're actively looking // for devices without keys to impersonate, so it should be fine. In theory, // those devices won't even be present but we're cautious. const devicesWithKeys = Array.from(Object.entries(userDeviceKeys)) .filter(d => d[0] === d[1].device_id && !!d[1].keys?.[`${DeviceKeyAlgorithm.Curve25519}:${d[1].device_id}`]) .map(t => t[0]); // grab device ID from tuple deviceId = ownDevices.find(d => !devicesWithKeys.includes(d.device_id))?.device_id; } } let prepared = false; if (deviceId) { this.makeClient(true); this.client.impersonateUserId(this.userId, deviceId); // verify that the server supports impersonating the device const respDeviceId = (await this.client.getWhoAmI()).device_id; prepared = (respDeviceId === deviceId); } if (!prepared) { // XXX: We work around servers that don't support device_id impersonation const accessToken = await Promise.resolve(storage?.readValue("accessToken")); if (!accessToken) { const loginBody = { type: "m.login.application_service", identifier: { type: "m.id.user", user: this.userId, }, }; const res = await this.client.doRequest("POST", "/_matrix/client/v3/login", {}, loginBody); this.makeClient(true, res['access_token']); storage.storeValue("accessToken", this.client.accessToken); prepared = true; } else { this.makeClient(true, accessToken); prepared = true; } } if (!prepared) {// noinspection ExceptionCaughtLocallyJS throw new Error("Unable to establish a device ID"); } // Now set up crypto await this.client.crypto.prepare(await this.refreshJoinedRooms()); this.appservice.on("room.event", (roomId, event) => { if (!this.knownJoinedRooms.includes(roomId)) return; this.client.crypto.onRoomEvent(roomId, event); }); resolve(); } catch (e) { reject(e); } }); } return this.cryptoSetupPromise; } /** * Gets the joined rooms for the intent. Note that by working around * the intent to join rooms may yield inaccurate results. * @returns {Promise} Resolves to an array of room IDs where * the intent is joined. */ @timedIntentFunctionCall() public async getJoinedRooms(): Promise { await this.ensureRegistered(); if (this.knownJoinedRooms.length === 0) await this.refreshJoinedRooms(); return this.knownJoinedRooms.map(r => r); // clone } /** * Leaves the given room. * @param {string} roomId The room ID to leave * @param {string=} reason Optional reason to be included as the reason for leaving the room. * @returns {Promise} Resolves when the room has been left. */ @timedIntentFunctionCall() public async leaveRoom(roomId: string, reason?: string): Promise { await this.ensureRegistered(); return this.client.leaveRoom(roomId, reason).then(async () => { // Recalculate joined rooms now that we've left a room await this.refreshJoinedRooms(); }); } /** * Joins the given room * @param {string} roomIdOrAlias the room ID or alias to join * @returns {Promise} resolves to the joined room ID */ @timedIntentFunctionCall() public async joinRoom(roomIdOrAlias: string): Promise { await this.ensureRegistered(); return this.client.joinRoom(roomIdOrAlias).then(async roomId => { // Recalculate joined rooms now that we've joined a room await this.refreshJoinedRooms(); return roomId; }); } /** * Sends a text message to a room. * @param {string} roomId The room ID to send text to. * @param {string} body The message body to send. * @param {"m.text" | "m.emote" | "m.notice"} msgtype The message type to send. * @returns {Promise} Resolves to the event ID of the sent message. */ @timedIntentFunctionCall() public async sendText(roomId: string, body: string, msgtype: "m.text" | "m.emote" | "m.notice" = "m.text"): Promise { return this.sendEvent(roomId, { body: body, msgtype: msgtype }); } /** * Sends an event to a room. * @param {string} roomId The room ID to send the event to. * @param {any} content The content of the event. * @returns {Promise} Resolves to the event ID of the sent event. */ @timedIntentFunctionCall() public async sendEvent(roomId: string, content: any): Promise { await this.ensureRegisteredAndJoined(roomId); return this.client.sendMessage(roomId, content); } /** * Ensures the user is registered and joined to the given room. * @param {string} roomId The room ID to join * @returns {Promise} Resolves when complete */ @timedIntentFunctionCall() public async ensureRegisteredAndJoined(roomId: string) { await this.ensureRegistered(); await this.ensureJoined(roomId); } /** * Ensures the user is joined to the given room * @param {string} roomId The room ID to join * @returns {Promise} Resolves when complete */ @timedIntentFunctionCall() public async ensureJoined(roomId: string) { if (this.knownJoinedRooms.indexOf(roomId) !== -1) { return; } await this.refreshJoinedRooms(); if (this.knownJoinedRooms.indexOf(roomId) !== -1) { return; } const returnedRoomId = await this.client.joinRoom(roomId); if (!this.knownJoinedRooms.includes(returnedRoomId)) { this.knownJoinedRooms.push(returnedRoomId); } return returnedRoomId; } /** * Refreshes which rooms the user is joined to, potentially saving time on * calls like ensureJoined() * @returns {Promise} Resolves to the joined room IDs for the user. */ @timedIntentFunctionCall() public async refreshJoinedRooms(): Promise { this.knownJoinedRooms = await this.client.getJoinedRooms(); return this.knownJoinedRooms.map(r => r); // clone } /** * Ensures the user is registered * @param deviceId An optional device ID to register with. * @returns {Promise} Resolves when complete */ @timedIntentFunctionCall() public async ensureRegistered(deviceId?: string) { if (!(await Promise.resolve(this.storage.isUserRegistered(this.userId)))) { try { const result = await this.client.doRequest("POST", "/_matrix/client/v3/register", null, { type: "m.login.application_service", username: this.userId.substring(1).split(":")[0], device_id: deviceId, }); // HACK: Workaround for unit tests if (result['errcode']) { // noinspection ExceptionCaughtLocallyJS throw { body: result }; // eslint-disable-line no-throw-literal } this.client.impersonateUserId(this.userId, result["device_id"]); } catch (err) { if (err instanceof MatrixError && err.errcode === "M_USER_IN_USE") { await Promise.resolve(this.storage.addRegisteredUser(this.userId)); if (this.userId === this.appservice.botUserId) { return null; } else { LogService.error("Appservice", "Error registering user: User ID is in use"); return null; } } else { LogService.error("Appservice", "Encountered error registering user: "); LogService.error("Appservice", extractRequestError(err)); } throw err; } await Promise.resolve(this.storage.addRegisteredUser(this.userId)); } } } ================================================ FILE: src/appservice/MatrixBridge.ts ================================================ import { Appservice } from "./Appservice"; import { Intent } from "./Intent"; export const REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE = "io.t2bot.sdk.bot.remote_user_info"; export const REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE = "io.t2bot.sdk.bot.remote_room_info"; export const REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX = "io.t2bot.sdk.bot.remote_user_map"; export const REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX = "io.t2bot.sdk.bot.remote_room_map"; /** * @see MatrixBridge * @category Application services */ export interface IRemoteRoomInfo { /** * A unique identifier for the remote user. */ id: string; } /** * @see MatrixBridge * @category Application services */ export interface IRemoteUserInfo { /** * A unique identifier for the remote room (or room equivalent). */ id: string; } /** * Utility class for common operations performed by bridges (represented * as appservices). * * The storage utilities are not intended for bridges which allow 1:many * relationships with the remote network. * * Bridges are generally expected to create their own classes which extend * the IRemoteRoomInfo and IRemoteUserInfo interfaces and serialize to JSON * cleanly. The serialized version of these classes is persisted in various * account data locations for future lookups. * @category Application services */ export class MatrixBridge { constructor(private appservice: Appservice) { } /** * Gets information about a remote user. * @param {Intent} userIntent The Matrix user intent to get information on. * @returns {Promise} Resolves to the remote user information. */ public async getRemoteUserInfo(userIntent: Intent): Promise { await userIntent.ensureRegistered(); return >userIntent.underlyingClient.getAccountData(REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE); } /** * Sets information about a remote user. Calling this function will map the * provided remote user ID to the intent's owner. * @param {Intent} userIntent The Matrix user intent to store information on. * @param {IRemoteUserInfo} remoteInfo The remote user information to store * @returns {Promise} Resolves when the information has been updated. */ public async setRemoteUserInfo(userIntent: Intent, remoteInfo: T): Promise { await userIntent.ensureRegistered(); await userIntent.underlyingClient.setAccountData(REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE, remoteInfo); await this.updateRemoteUserMapping(userIntent.userId, remoteInfo.id); } /** * Gets information about a remote room. * @param {string} matrixRoomId The Matrix room ID to get information on. * @returns {Promise} Resolves to the remote room information. */ public async getRemoteRoomInfo(matrixRoomId: string): Promise { const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); // We do not need to ensure the user is joined to the room because we can associate // room account data with any arbitrary room. return >bridgeBot.underlyingClient.getRoomAccountData(REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE, matrixRoomId); } /** * Sets information about a remote room. Calling this function will map the * provided remote room ID to the matrix room ID. * @param {string} matrixRoomId The Matrix room ID to store information on. * @param {IRemoteRoomInfo} remoteInfo The remote room information to store * @returns {Promise} Resolves when the information has been updated. */ public async setRemoteRoomInfo(matrixRoomId: string, remoteInfo: T): Promise { const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); // We do not need to ensure the user is joined to the room because we can associate // room account data with any arbitrary room. await bridgeBot.underlyingClient.setRoomAccountData(REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE, matrixRoomId, remoteInfo); await this.updateRemoteRoomMapping(matrixRoomId, remoteInfo.id); } /** * Gets the Matrix room ID for the provided remote room ID. * @param {string} remoteRoomId The remote room ID to look up. * @returns {Promise} Resolves to the Matrix room ID. */ public async getMatrixRoomIdForRemote(remoteRoomId: string): Promise { const eventType = `${REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX}.${remoteRoomId}`; const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); const result = await bridgeBot.underlyingClient.getAccountData(eventType); return result['id']; } /** * Gets a Matrix user intent for the provided remote user ID. * @param {string} remoteUserId The remote user ID to look up. * @returns {Promise} Resolves to the Matrix user intent. */ public async getIntentForRemote(remoteUserId: string): Promise { const eventType = `${REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX}.${remoteUserId}`; const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); const result = await bridgeBot.underlyingClient.getAccountData(eventType); return this.appservice.getIntentForUserId(result['id']); } private async updateRemoteUserMapping(matrixUserId: string, remoteUserId: string): Promise { const eventType = `${REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX}.${remoteUserId}`; const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); await bridgeBot.underlyingClient.setAccountData(eventType, { id: matrixUserId }); } private async updateRemoteRoomMapping(matrixRoomId: string, remoteRoomId: string): Promise { const eventType = `${REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX}.${remoteRoomId}`; const bridgeBot = this.appservice.botIntent; await bridgeBot.ensureRegistered(); await bridgeBot.underlyingClient.setAccountData(eventType, { id: matrixRoomId }); } } ================================================ FILE: src/appservice/UnstableAppserviceApis.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { MSC2716BatchSendResponse } from "../models/MSC2176"; /** * Unstable APIs that shouldn't be used in most circumstances for appservices. * @category Unstable APIs */ export class UnstableAppserviceApis { private requestId = 0; constructor(private client: MatrixClient) { } /** * Send several historical events into a room. * @see https://github.com/matrix-org/matrix-doc/pull/2716 * @param {string} roomId The roomID to send to. * @param {string} prevEventId The event ID where this batch will be inserted * @param {string} chunkId The chunk ID returned from a previous call. Set falsy to start at the beginning. * @param {any[]} events A set of event contents for events to be inserted into the room. * @param {any[]} stateEventsAtStart A set of state events to be inserted into the room. Defaults to empty. * @returns A set of eventIds and the next chunk ID */ public async sendHistoricalEventBatch(roomId: string, prevEventId: string, events: any[], stateEventsAtStart: any[] = [], chunkId?: string): Promise { return this.client.doRequest("POST", `/_matrix/client/unstable/org.matrix.msc2716/rooms/${encodeURIComponent(roomId)}/batch_send`, { prev_event: prevEventId, chunk_id: chunkId, }, { events, state_events_at_start: stateEventsAtStart, }); } /** * Sends an event to the given room with a given timestamp. * @param {string} roomId the room ID to send the event to * @param {string} eventType the type of event to send * @param {string} content the event body to send * @param {number} ts The origin_server_ts of the new event * @returns {Promise} resolves to the event ID that represents the event */ public async sendEventWithTimestamp(roomId: string, eventType: string, content: any, ts: number) { const txnId = `${(new Date().getTime())}__inc_appts${++this.requestId}`; const path = `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`; const response = await this.client.doRequest("PUT", path, { ts }, content); return response.event_id; } /** * Sends a state event to the given room with a given timestamp. * @param {string} roomId the room ID to send the event to * @param {string} type the event type to send * @param {string} stateKey the state key to send, should not be null * @param {string} content the event body to send * @param {number} ts The origin_server_ts of the new event * @returns {Promise} resolves to the event ID that represents the message */ public async sendStateEventWithTimestamp(roomId: string, type: string, stateKey: string, content: any, ts: number): Promise { const path = `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(type)}/${encodeURIComponent(stateKey)}`; const response = await this.client.doRequest("PUT", path, { ts }, content); return response.event_id; } } ================================================ FILE: src/appservice/http_responses.ts ================================================ /** * This is the response format documented in * https://matrix.org/docs/spec/application_service/r0.1.2#get-matrix-app-v1-thirdparty-protocol-protocol * @category Application services */ export interface IApplicationServiceProtocol { user_fields: string[]; location_fields: string[]; icon: string; field_types: { [field: string]: IFieldType }; instances: { [name: string]: IProtocolInstance }; } interface IFieldType { regexp: string; placeholder: string; } interface IProtocolInstance { desc: string; icon: string; fields: { [field: string]: string }; network_id: string; } /** * This is the response format for an MSC3983 `/keys/claim` request. * See https://github.com/matrix-org/matrix-spec-proposals/pull/3983 * @deprecated This can be removed at any time without notice as it is unstable functionality. * @category Application services */ export interface MSC3983KeyClaimResponse { [userId: string]: { [deviceId: string]: { [keyId: string]: { // for signed_curve25519 keys key: string; signatures: { [userId: string]: { [keyId: string]: string; }; }; }; }; }; } /** * This is the response format for an MSC3984 `/keys/query` request. * See https://github.com/matrix-org/matrix-spec-proposals/pull/3984 * @deprecated This can be removed at any time without notice as it is unstable functionality. * @category Application services */ export interface MSC3984KeyQueryResponse { device_keys: { [userId: string]: { [deviceId: string]: { algorithms: string[]; device_id: string; user_id: string; keys: { [keyId: string]: string; }; signatures: { [userId: string]: { [keyId: string]: string; }; }; unsigned?: { [key: string]: any; }; }; }; }; // TODO: Cross-signing support } ================================================ FILE: src/b64.ts ================================================ /** * Encodes Base64. * @category Utilities * @param {ArrayBuffer | Uint8Array} b The buffer to encode. * @returns {string} The Base64 string. */ export function encodeBase64(b: ArrayBuffer | Uint8Array): string { if (b instanceof ArrayBuffer) { return Buffer.from(b).toString('base64'); } else { return Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString('base64'); } } /** * Encodes Unpadded Base64. * @category Utilities * @param {ArrayBuffer | Uint8Array} b The buffer to encode. * @returns {string} The Base64 string. */ export function encodeUnpaddedBase64(b: ArrayBuffer | Uint8Array): string { return encodeBase64(b).replace(/=+/g, ''); } /** * Encodes URL-Safe Unpadded Base64. * @category Utilities * @param {ArrayBuffer | Uint8Array} b The buffer to encode. * @returns {string} The Base64 string. */ export function encodeUnpaddedUrlSafeBase64(b: ArrayBuffer | Uint8Array): string { return encodeUnpaddedBase64(b).replace(/\+/g, '-').replace(/\//g, '_'); } /** * Decodes Base64. * @category Utilities * @param {string} s The Base64 string. * @returns {Uint8Array} The encoded data as a buffer. */ export function decodeBase64(s: string): Uint8Array { return Buffer.from(s, 'base64'); } /** * Decodes Unpadded Base64. * @category Utilities * @param {string} s The Base64 string. * @returns {Uint8Array} The encoded data as a buffer. */ export function decodeUnpaddedBase64(s: string): Uint8Array { return decodeBase64(s); // yay, it's the same } /** * Decodes URL-Safe Unpadded Base64. * @category Utilities * @param {string} s The Base64 string. * @returns {Uint8Array} The encoded data as a buffer. */ export function decodeUnpaddedUrlSafeBase64(s: string): Uint8Array { return decodeUnpaddedBase64(s.replace(/-/g, '+').replace(/_/g, '/')); } ================================================ FILE: src/e2ee/CryptoClient.ts ================================================ import { DeviceId, OlmMachine, UserId, DeviceLists, RoomId, Attachment, EncryptedAttachment, } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { MatrixClient } from "../MatrixClient"; import { LogService } from "../logging/LogService"; import { IMegolmEncrypted, IOlmEncrypted, IToDeviceMessage, OTKAlgorithm, OTKCounts, Signatures, } from "../models/Crypto"; import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent"; import { RoomEvent } from "../models/events/RoomEvent"; import { EncryptedFile } from "../models/events/MessageEvent"; import { RustSdkCryptoStorageProvider } from "../storage/RustSdkCryptoStorageProvider"; import { RustEngine, SYNC_LOCK_NAME } from "./RustEngine"; import { MembershipEvent } from "../models/events/MembershipEvent"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly * rather than creating one manually. * @category Encryption */ export class CryptoClient { private ready = false; private deviceId: string; private deviceEd25519: string; private deviceCurve25519: string; private roomTracker: RoomTracker; private engine: RustEngine; public constructor(private client: MatrixClient) { this.roomTracker = new RoomTracker(this.client); } private get storage(): RustSdkCryptoStorageProvider { return this.client.cryptoStore; } /** * The device ID for the MatrixClient. */ public get clientDeviceId(): string { return this.deviceId; } /** * The device's Ed25519 identity */ public get clientDeviceEd25519(): string { return this.deviceEd25519; } /** * Whether or not the crypto client is ready to be used. If not ready, prepare() should be called. * @see prepare */ public get isReady(): boolean { return this.ready; } /** * Prepares the crypto client for usage. * @param {string[]} roomIds The room IDs the MatrixClient is joined to. */ public async prepare(roomIds: string[]) { await this.roomTracker.prepare(roomIds); if (this.ready) return; // stop re-preparing here const storedDeviceId = await this.client.cryptoStore.getDeviceId(); if (storedDeviceId) { this.deviceId = storedDeviceId; } else { const deviceId = (await this.client.getWhoAmI())['device_id']; if (!deviceId) { throw new Error("Encryption not possible: server not revealing device ID"); } this.deviceId = deviceId; await this.client.cryptoStore.setDeviceId(this.deviceId); } LogService.info("CryptoClient", "Starting with device ID:", this.deviceId); // info so all bots know for debugging const machine = await OlmMachine.initialize( new UserId(await this.client.getUserId()), new DeviceId(this.deviceId), this.storage.storagePath, "", this.storage.storageType, ); this.engine = new RustEngine(machine, this.client); await this.engine.run(); const identity = this.engine.machine.identityKeys; this.deviceCurve25519 = identity.curve25519.toBase64(); this.deviceEd25519 = identity.ed25519.toBase64(); LogService.debug("CryptoClient", "Running with device Ed25519 identity:", this.deviceEd25519); // info so all bots know for debugging this.ready = true; } /** * Handles a room event. * @internal * @param roomId The room ID. * @param event The event. */ public async onRoomEvent(roomId: string, event: any) { await this.roomTracker.onRoomEvent(roomId, event); if (typeof event['state_key'] !== 'string') return; if (event['type'] === 'm.room.member') { const membership = new MembershipEvent(event); if (membership.effectiveMembership !== 'join' && membership.effectiveMembership !== 'invite') return; await this.engine.addTrackedUsers([membership.membershipFor]); } else if (event['type'] === 'm.room.encryption') { const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']); await this.engine.addTrackedUsers(members.map(e => e.membershipFor)); } } /** * Handles a room join. * @internal * @param roomId The room ID. */ public async onRoomJoin(roomId: string) { await this.roomTracker.onRoomJoin(roomId); if (await this.isRoomEncrypted(roomId)) { const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']); await this.engine.addTrackedUsers(members.map(e => e.membershipFor)); } } /** * Checks if a room is encrypted. * @param {string} roomId The room ID to check. * @returns {Promise} Resolves to true if encrypted, false otherwise. */ @requiresReady() public async isRoomEncrypted(roomId: string): Promise { const config = await this.roomTracker.getRoomCryptoConfig(roomId); return !!config?.algorithm; } /** * Updates the client's sync-related data. * @param {Array.>} toDeviceMessages The to-device messages received. * @param {OTKCounts} otkCounts The current OTK counts. * @param {OTKAlgorithm[]} unusedFallbackKeyAlgs The unused fallback key algorithms. * @param {string[]} changedDeviceLists The user IDs which had device list changes. * @param {string[]} leftDeviceLists The user IDs which the server believes we no longer need to track. * @returns {Promise} Resolves when complete. */ @requiresReady() public async updateSyncData( toDeviceMessages: IToDeviceMessage[], otkCounts: OTKCounts, unusedFallbackKeyAlgs: OTKAlgorithm[], changedDeviceLists: string[], leftDeviceLists: string[], ): Promise { const deviceMessages = JSON.stringify(toDeviceMessages); const deviceLists = new DeviceLists( changedDeviceLists.map(u => new UserId(u)), leftDeviceLists.map(u => new UserId(u))); await this.engine.lock.acquire(SYNC_LOCK_NAME, async () => { const syncResp = await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs); const decryptedToDeviceMessages = JSON.parse(syncResp); if (Array.isArray(decryptedToDeviceMessages)) { for (const msg of decryptedToDeviceMessages) { this.client.emit("to_device.decrypted", msg); } } await this.engine.run(); }); } /** * Signs an object using the device keys. * @param {object} obj The object to sign. * @returns {Promise} The signatures for the object. */ @requiresReady() public async sign(obj: object): Promise { obj = JSON.parse(JSON.stringify(obj)); const existingSignatures = obj['signatures'] || {}; delete obj['signatures']; delete obj['unsigned']; const container = await this.engine.machine.sign(JSON.stringify(obj)); const userSignature = container.get(new UserId(await this.client.getUserId())); const sig: Signatures = { [await this.client.getUserId()]: {}, }; for (const [key, maybeSignature] of Object.entries(userSignature)) { if (maybeSignature.isValid) { sig[await this.client.getUserId()][key] = maybeSignature.signature.toBase64(); } } return { ...sig, ...existingSignatures, }; } /** * Encrypts the details of a room event, returning an encrypted payload to be sent in an * `m.room.encrypted` event to the room. If needed, this function will send decryption keys * to the appropriate devices in the room (this happens when the Megolm session rotates or * gets created). * @param {string} roomId The room ID to encrypt within. If the room is not encrypted, an * error is thrown. * @param {string} eventType The event type being encrypted. * @param {any} content The event content being encrypted. * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. */ @requiresReady() public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { if (!(await this.isRoomEncrypted(roomId))) { throw new Error("Room is not encrypted"); } await this.engine.prepareEncrypt(roomId, await this.roomTracker.getRoomCryptoConfig(roomId)); const encrypted = JSON.parse(await this.engine.machine.encryptRoomEvent(new RoomId(roomId), eventType, JSON.stringify(content))); await this.engine.run(); return encrypted as IMegolmEncrypted; } /** * Decrypts a room event. Currently only supports Megolm-encrypted events (default for this SDK). * @param {EncryptedRoomEvent} event The encrypted event. * @param {string} roomId The room ID where the event was sent. * @returns {Promise>} Resolves to a decrypted room event, or rejects/throws with * an error if the event is undecryptable. */ @requiresReady() public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise> { const decrypted = await this.engine.machine.decryptRoomEvent(JSON.stringify(event.raw), new RoomId(roomId)); const clearEvent = JSON.parse(decrypted.event); return new RoomEvent({ ...event.raw, type: clearEvent.type || "io.t2bot.unknown", content: (typeof (clearEvent.content) === 'object') ? clearEvent.content : {}, }); } /** * Encrypts a file for uploading in a room, returning the encrypted data and information * to include in a message event (except media URL) for sending. * @param {Buffer} file The file to encrypt. * @returns {{buffer: Buffer, file: Omit}} Resolves to the encrypted * contents and file information. */ @requiresReady() public async encryptMedia(file: Buffer): Promise<{ buffer: Buffer, file: Omit }> { const encrypted = Attachment.encrypt(file); const info = JSON.parse(encrypted.mediaEncryptionInfo); return { buffer: Buffer.from(encrypted.encryptedData), file: info, }; } /** * Decrypts a previously-uploaded encrypted file, validating the fields along the way. * @param {EncryptedFile} file The file to decrypt. * @returns {Promise} Resolves to the decrypted file contents. */ @requiresReady() public async decryptMedia(file: EncryptedFile): Promise { const contents = (await this.client.downloadContent(file.url)).data; const encrypted = new EncryptedAttachment( contents, JSON.stringify(file), ); const decrypted = Attachment.decrypt(encrypted); return Buffer.from(decrypted); } } ================================================ FILE: src/e2ee/ICryptoRoomInformation.ts ================================================ import { EncryptionEventContent } from "../models/events/EncryptionEvent"; /** * Information about a room for the purposes of crypto. * @category Encryption */ export interface ICryptoRoomInformation extends Partial { historyVisibility?: string; } ================================================ FILE: src/e2ee/RoomTracker.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { EncryptionEventContent } from "../models/events/EncryptionEvent"; import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; // noinspection ES6RedundantAwait /** * Tracks room encryption status for a MatrixClient. * @category Encryption */ export class RoomTracker { public constructor(private client: MatrixClient) { } /** * Handles a room join * @internal * @param roomId The room ID. */ public async onRoomJoin(roomId: string) { await this.queueRoomCheck(roomId); } /** * Handles a room event. * @internal * @param roomId The room ID. * @param event The event. */ public async onRoomEvent(roomId: string, event: any) { if (event['state_key'] !== '') return; // we don't care about anything else if (event['type'] === 'm.room.encryption' || event['type'] === 'm.room.history_visibility') { await this.queueRoomCheck(roomId); } } /** * Prepares the room tracker to track the given rooms. * @param {string[]} roomIds The room IDs to track. This should be the joined rooms set. */ public async prepare(roomIds: string[]) { for (const roomId of roomIds) { await this.queueRoomCheck(roomId); } } /** * Queues a room check for the tracker. If the room needs an update to the store, an * update will be made. * @param {string} roomId The room ID to check. */ public async queueRoomCheck(roomId: string) { const config = await this.client.cryptoStore.getRoom(roomId); if (config) { if (config.algorithm !== undefined) { return; // assume no change to encryption config } } let encEvent: Partial; try { encEvent = await this.client.getRoomStateEvent(roomId, "m.room.encryption", ""); encEvent.algorithm = encEvent.algorithm ?? 'UNKNOWN'; } catch (e) { return; // failure == no encryption } // Pick out the history visibility setting too let historyVisibility: string; try { const ev = await this.client.getRoomStateEvent(roomId, "m.room.history_visibility", ""); historyVisibility = ev.history_visibility; } catch (e) { // ignore - we'll just treat history visibility as normal } await this.client.cryptoStore.storeRoom(roomId, { ...encEvent, historyVisibility, }); } /** * Gets the room's crypto configuration, as known by the underlying store. If the room is * not encrypted then this will return an empty object. * @param {string} roomId The room ID to get the config for. * @returns {Promise} Resolves to the encryption config. */ public async getRoomCryptoConfig(roomId: string): Promise { let config = await this.client.cryptoStore.getRoom(roomId); if (!config) { await this.queueRoomCheck(roomId); config = await this.client.cryptoStore.getRoom(roomId); } if (!config) { return {}; } return config; } } ================================================ FILE: src/e2ee/RustEngine.ts ================================================ import { EncryptionSettings, KeysClaimRequest, OlmMachine, RequestType, RoomId, UserId, EncryptionAlgorithm as RustEncryptionAlgorithm, HistoryVisibility, KeysUploadRequest, KeysQueryRequest, ToDeviceRequest, } from "@matrix-org/matrix-sdk-crypto-nodejs"; import * as AsyncLock from "async-lock"; import { MatrixClient } from "../MatrixClient"; import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; import { EncryptionAlgorithm } from "../models/Crypto"; import { EncryptionEvent } from "../models/events/EncryptionEvent"; /** * @internal */ export const SYNC_LOCK_NAME = "sync"; /** * @internal */ export class RustEngine { public readonly lock = new AsyncLock(); public constructor(public readonly machine: OlmMachine, private client: MatrixClient) { } public async run() { await this.runOnly(); // run everything, but with syntactic sugar } private async runOnly(...types: RequestType[]) { // Note: we should not be running this until it runs out, so cache the value into a variable const requests = await this.machine.outgoingRequests(); for (const request of requests) { if (types.length && !types.includes(request.type)) continue; switch (request.type) { case RequestType.KeysUpload: await this.processKeysUploadRequest(request); break; case RequestType.KeysQuery: await this.processKeysQueryRequest(request); break; case RequestType.KeysClaim: await this.processKeysClaimRequest(request); break; case RequestType.ToDevice: await this.processToDeviceRequest(request as ToDeviceRequest); break; case RequestType.RoomMessage: throw new Error("Bindings error: Sending room messages is not supported"); case RequestType.SignatureUpload: throw new Error("Bindings error: Backup feature not possible"); case RequestType.KeysBackup: throw new Error("Bindings error: Backup feature not possible"); default: throw new Error("Bindings error: Unrecognized request type: " + request.type); } } } public async addTrackedUsers(userIds: string[]) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { const uids = userIds.map(u => new UserId(u)); await this.machine.updateTrackedUsers(uids); const keysClaim = await this.machine.getMissingSessions(uids); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); } public async prepareEncrypt(roomId: string, roomInfo: ICryptoRoomInformation) { // TODO: Handle pre-shared invite keys too const members = (await this.client.getJoinedRoomMembers(roomId)).map(u => new UserId(u)); let historyVis = HistoryVisibility.Joined; switch (roomInfo.historyVisibility) { case "world_readable": historyVis = HistoryVisibility.WorldReadable; break; case "invited": historyVis = HistoryVisibility.Invited; break; case "shared": historyVis = HistoryVisibility.Shared; break; case "joined": default: // Default and other cases handled by assignment before switch } const encEv = new EncryptionEvent({ type: "m.room.encryption", content: roomInfo, }); const settings = new EncryptionSettings(); settings.algorithm = roomInfo.algorithm === EncryptionAlgorithm.MegolmV1AesSha2 ? RustEncryptionAlgorithm.MegolmV1AesSha2 : undefined; settings.historyVisibility = historyVis; settings.rotationPeriod = BigInt(encEv.rotationPeriodMs); settings.rotationPeriodMessages = BigInt(encEv.rotationPeriodMessages); await this.lock.acquire(SYNC_LOCK_NAME, async () => { await this.machine.updateTrackedUsers(members); // just in case we missed some await this.runOnly(RequestType.KeysQuery); const keysClaim = await this.machine.getMissingSessions(members); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); await this.lock.acquire(roomId, async () => { const requests = await this.machine.shareRoomKey(new RoomId(roomId), members, settings); for (const req of requests) { await this.actuallyProcessToDeviceRequest(req.txnId, req.eventType, JSON.parse(req.body)["messages"]); } }); } private async processKeysClaimRequest(request: KeysClaimRequest) { const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/claim", null, JSON.parse(request.body)); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processKeysUploadRequest(request: KeysUploadRequest) { const body = JSON.parse(request.body); // delete body["one_time_keys"]; // use this to test MSC3983 const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/upload", null, body); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processKeysQueryRequest(request: KeysQueryRequest) { const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/query", null, JSON.parse(request.body)); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processToDeviceRequest(request: ToDeviceRequest) { const req = JSON.parse(request.body); await this.actuallyProcessToDeviceRequest(req.txn_id, req.event_type, req.messages); } private async actuallyProcessToDeviceRequest(id: string, type: string, messages: Record>) { const resp = await this.client.sendToDevices(type, messages); await this.machine.markRequestAsSent(id, RequestType.ToDevice, JSON.stringify(resp)); } } ================================================ FILE: src/e2ee/decorators.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { CryptoClient } from "./CryptoClient"; /** * Flags a MatrixClient function as needing end-to-end encryption enabled. * @category Encryption */ export function requiresCrypto() { return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function(...args: any[]) { const client: MatrixClient = this; // eslint-disable-line @typescript-eslint/no-this-alias if (!client.crypto) { throw new Error("End-to-end encryption is not enabled"); } return originalMethod.apply(this, args); }; }; } /** * Flags a CryptoClient function as needing the CryptoClient to be ready. * @category Encryption */ export function requiresReady() { return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function(...args: any[]) { const crypto: CryptoClient = this; // eslint-disable-line @typescript-eslint/no-this-alias if (!crypto.isReady) { throw new Error("End-to-end encryption has not initialized"); } return originalMethod.apply(this, args); }; }; } ================================================ FILE: src/helpers/MatrixEntity.ts ================================================ /** * Represents a Matrix entity * @category Utilities */ export class MatrixEntity { private entityLocalpart: string; private entityDomain: string; /** * Creates a new Matrix entity * @param {string} fullId The full ID of the entity */ constructor(private fullId: string) { if (!fullId) throw new Error("No entity ID provided"); if (fullId.length < 2) throw new Error("ID too short"); const parts = fullId.split(/:/g); this.entityLocalpart = parts[0].substring(1); this.entityDomain = parts.splice(1).join(':'); } /** * The localpart for the entity */ public get localpart(): string { return this.entityLocalpart; } /** * The domain for the entity */ public get domain(): string { return this.entityDomain; } // override public toString(): string { return this.fullId; } } /** * Represents a Matrix user ID * @category Utilities */ export class UserID extends MatrixEntity { constructor(userId: string) { super(userId); if (!userId.startsWith("@")) { throw new Error("Not a valid user ID"); } } } /** * Represents a Matrix room alias * @category Utilities */ export class RoomAlias extends MatrixEntity { constructor(alias: string) { super(alias); if (!alias.startsWith("#")) { throw new Error("Not a valid room alias"); } } } ================================================ FILE: src/helpers/MatrixGlob.ts ================================================ import * as globToRegexp from "glob-to-regexp"; /** * Represents a common Matrix glob. This is commonly used * for server ACLs and similar functions. * @category Utilities */ export class MatrixGlob { /** * The regular expression which represents this glob. */ public readonly regex: RegExp; /** * Creates a new Matrix Glob * @param {string} glob The glob to convert. Eg: "*.example.org" */ constructor(glob: string) { const globRegex = globToRegexp(glob, { extended: false, globstar: false, }); // We need to convert `?` manually because globToRegexp's extended mode // does more than we want it to. const replaced = globRegex.toString().replace(/\\\?/g, "."); this.regex = new RegExp(replaced.substring(1, replaced.length - 1)); } /** * Tests the glob against a value, returning true if it matches. * @param {string} val The value to test. * @returns {boolean} True if the value matches the glob, false otherwise. */ public test(val: string): boolean { return this.regex.test(val); } } ================================================ FILE: src/helpers/MentionPill.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { Permalinks } from "./Permalinks"; import { extractRequestError, LogService } from ".."; /** * Represents a system for generating a mention pill for an entity. * @category Utilities */ export class MentionPill { private constructor(private entityPermalink: string, private displayName: string) { } /** * The HTML component of the mention. */ public get html(): string { return `${this.displayName}`; } /** * The plain text component of the mention. */ public get text(): string { return this.displayName; } /** * Creates a new mention for a user in an optional room. * @param {string} userId The user ID the mention is for. * @param {String} inRoomId Optional room ID the user is being mentioned in, for the aesthetics of the mention. * @param {MatrixClient} client Optional client for creating a more pleasing mention. * @returns {Promise} Resolves to the user's mention. */ public static async forUser(userId: string, inRoomId: string = null, client: MatrixClient = null): Promise { const permalink = Permalinks.forUser(userId); let displayName = userId; try { if (client) { let profile = null; if (inRoomId) { profile = await client.getRoomStateEvent(inRoomId, "m.room.member", userId); } if (!profile) { profile = await client.getUserProfile(userId); } if (profile['displayname']) { displayName = profile['displayname']; } } } catch (e) { LogService.warn("MentionPill", "Error getting profile", extractRequestError(e)); } return new MentionPill(permalink, displayName); } /** * Creates a new mention for a room (not @room, but the room itself to be linked). * @param {string} roomIdOrAlias The room ID or alias to mention. * @param {MatrixClient} client Optional client for creating a more pleasing mention. * @returns {Promise} Resolves to the room's mention. */ public static async forRoom(roomIdOrAlias: string, client: MatrixClient = null): Promise { let permalink = Permalinks.forRoom(roomIdOrAlias); let displayProp = roomIdOrAlias; try { if (client) { const roomId = await client.resolveRoom(roomIdOrAlias); const canonicalAlias = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", ""); if (canonicalAlias?.alias) { displayProp = canonicalAlias.alias; permalink = Permalinks.forRoom(displayProp); } } } catch (e) { LogService.warn("MentionPill", "Error getting room information", extractRequestError(e)); } return new MentionPill(permalink, displayProp); } /** * Creates a mention from static information. * @param {string} userId The user ID the mention is for. * @param {string} displayName The user's display name. * @returns {MentionPill} The mention for the user. */ public static withDisplayName(userId: string, displayName: string): MentionPill { return new MentionPill(Permalinks.forUser(userId), displayName || userId); } } ================================================ FILE: src/helpers/Permalinks.ts ================================================ /** * The parts of a permalink. * @see Permalinks * @category Utilities */ export interface PermalinkParts { /** * The room ID or alias the permalink references. May be undefined. */ roomIdOrAlias: string; /** * The user ID the permalink references. May be undefined. */ userId: string; /** * The event ID the permalink references. May be undefined. */ eventId: string; /** * The servers the permalink is routed through. May be undefined or empty. */ viaServers: string[]; } /** * Functions for handling permalinks * @category Utilities */ export class Permalinks { private constructor() { } // TODO: Encode permalinks private static encodeViaArgs(servers: string[]): string { if (!servers || !servers.length) return ""; return `?via=${servers.join("&via=")}`; } /** * Creates a room permalink. * @param {string} roomIdOrAlias The room ID or alias to create a permalink for. * @param {string[]} viaServers The servers to route the permalink through. * @returns {string} A room permalink. */ public static forRoom(roomIdOrAlias: string, viaServers: string[] = []): string { return `https://matrix.to/#/${roomIdOrAlias}${Permalinks.encodeViaArgs(viaServers)}`; } /** * Creates a user permalink. * @param {string} userId The user ID to create a permalink for. * @returns {string} A user permalink. */ public static forUser(userId: string): string { return `https://matrix.to/#/${userId}`; } /** * Creates an event permalink. * @param {string} roomIdOrAlias The room ID or alias to create a permalink in. * @param {string} eventId The event ID to reference in the permalink. * @param {string[]} viaServers The servers to route the permalink through. * @returns {string} An event permalink. */ public static forEvent(roomIdOrAlias: string, eventId: string, viaServers: string[] = []): string { return `https://matrix.to/#/${roomIdOrAlias}/${eventId}${Permalinks.encodeViaArgs(viaServers)}`; } /** * Parses a permalink URL into usable parts. * @param {string} matrixTo The matrix.to URL to parse. * @returns {PermalinkParts} The parts of the permalink. */ public static parseUrl(matrixTo: string): PermalinkParts { const matrixToRegexp = /^https:\/\/matrix\.to\/#\/(?[^/?]+)\/?(?[^?]+)?(?\?[^]*)?$/; const url = matrixToRegexp.exec(matrixTo)?.groups; if (!url) { throw new Error("Not a valid matrix.to URL"); } const entity = decodeURIComponent(url.entity); if (entity[0] === '@') { return { userId: entity, roomIdOrAlias: undefined, eventId: undefined, viaServers: undefined }; } else if (entity[0] === '#' || entity[0] === '!') { return { userId: undefined, roomIdOrAlias: entity, eventId: url.eventId && decodeURIComponent(url.eventId), viaServers: new URLSearchParams(url.query).getAll('via'), }; } else { throw new Error("Unexpected entity"); } } } ================================================ FILE: src/helpers/ProfileCache.ts ================================================ import * as LRU from "lru-cache"; import { extractRequestError, LogService, MatrixClient, MatrixProfile } from ".."; import { MembershipEvent } from "../models/events/MembershipEvent"; import { Appservice } from "../appservice/Appservice"; type CacheKey = `${string}@${string | ''}`; /** * Functions for avoiding calls to profile endpoints. Useful for bots when * people are mentioned often or bridges which need profile information * often. * @category Utilities */ export class ProfileCache { private cache: LRU.LRUCache; /** * Creates a new profile cache. * @param {number} maxEntries The maximum number of entries to cache. * @param {number} maxAgeMs The maximum age of an entry in milliseconds. * @param {MatrixClient} client The client to use to get profile updates. */ constructor(maxEntries: number, maxAgeMs: number, private client: MatrixClient) { this.cache = new LRU.LRUCache({ max: maxEntries, ttl: maxAgeMs, }); } private getCacheKey(userId: string, roomId: string | null): CacheKey { return `${userId}@${roomId || ''}`; } /** * Watch for profile changes to cached entries with the provided client. The * same client will also be used to update the user's profile in the cache. * @param {MatrixClient} client The client to watch for profile changes with. */ public watchWithClient(client: MatrixClient) { client.on("room.event", async (roomId: string, event: string) => { if (!event['state_key'] || !event['content'] || event['type'] !== 'm.room.member') return; await this.tryUpdateProfile(roomId, new MembershipEvent(event), client); }); } /** * Watch for profile changes to cached entries with the provided application * service. The clientFn will be called to get the relevant client for any * updates. If the clientFn is null, the appservice's bot user will be used. * The clientFn takes two arguments: the user ID being updated and the room ID * they are being updated in (shouldn't be null). The return value should be the * MatrixClient to use, or null to use the appservice's bot client. The same * client will be used to update the user's general profile, if that profile * is cached. * @param {Appservice} appservice The application service to watch for profile changes with. * @param {Function} clientFn The function to use to acquire profile updates with. If null, the appservice's bot client will be used. */ public watchWithAppservice(appservice: Appservice, clientFn: (userId: string, roomId: string) => MatrixClient = null) { if (!clientFn) clientFn = () => appservice.botClient; appservice.on("room.event", async (roomId: string, event: string) => { if (!event['state_key'] || !event['content'] || event['type'] !== 'm.room.member') return; const memberEvent = new MembershipEvent(event); let client = clientFn(memberEvent.membershipFor, roomId); if (!client) client = appservice.botClient; await this.tryUpdateProfile(roomId, memberEvent, client); }); } /** * Gets a profile for a user in optional room. * @param {string} userId The user ID to get a profile for. * @param {string|null} roomId Optional room ID to get a per-room profile for the user. * @returns {Promise} Resolves to the user's profile. */ public async getUserProfile(userId: string, roomId: string = null): Promise { const cacheKey = this.getCacheKey(userId, roomId); const cached = this.cache.get(cacheKey); if (cached) return Promise.resolve(cached); const profile = await this.getUserProfileWith(userId, roomId, this.client); this.cache.set(cacheKey, profile); return profile; } private async getUserProfileWith(userId: string, roomId: string, client: MatrixClient): Promise { try { if (roomId) { const membership = await client.getRoomStateEvent(roomId, "m.room.member", userId); return new MatrixProfile(userId, membership); } else { const profile = await client.getUserProfile(userId); return new MatrixProfile(userId, profile); } } catch (e) { LogService.warn("ProfileCache", "Non-fatal error getting user profile. They might not exist."); LogService.warn("ProfileCache", extractRequestError(e)); return new MatrixProfile(userId, {}); } } private async tryUpdateProfile(roomId: string, memberEvent: MembershipEvent, client: MatrixClient) { const roomCacheKey = this.getCacheKey(memberEvent.membershipFor, roomId); const generalCacheKey = this.getCacheKey(memberEvent.membershipFor, null); if (this.cache.has(roomCacheKey)) { this.cache.set(roomCacheKey, new MatrixProfile(memberEvent.membershipFor, memberEvent.content)); } // TODO: Try and figure out semantics for this updating. // Large accounts could cause hammering on the profile endpoint, but hopefully it is cached by the server. if (this.cache.has(generalCacheKey)) { const profile = await this.getUserProfileWith(memberEvent.membershipFor, null, client); this.cache.set(generalCacheKey, profile); } } } ================================================ FILE: src/helpers/RichReply.ts ================================================ import * as sanitizeHtml from "sanitize-html"; /** * Helper for creating rich replies. * @category Utilities */ export class RichReply { private constructor() { } /** * Generates the event content required to reply to the provided event with the * provided text. * @param {string} roomId the room ID the event being replied to resides in * @param {any} event the event to reply to * @param {string} withText the plain text to reply with * @param {string} withHtml the HTML to reply with * @returns {any} the content of the event representing the reply */ public static createFor(roomId: string, event: any, withText: string, withHtml: string): any { const originalBody = (event["content"] ? event["content"]["body"] : "") || ""; let originalHtml = (event["content"] ? event["content"]["formatted_body"] : "") || null; if (originalHtml === null) { originalHtml = sanitizeHtml(originalBody); } const fallbackText = "> <" + event["sender"] + "> " + originalBody.split("\n").join("\n> "); const fallbackHtml = "
" + `In reply to ` + `${event["sender"]}` + "
" + originalHtml + "
"; return { "m.relates_to": { "m.in_reply_to": { "event_id": event["event_id"], }, }, "msgtype": "m.text", // for those who just want to send the reply as-is "body": fallbackText + "\n\n" + withText, "format": "org.matrix.custom.html", "formatted_body": fallbackHtml + withHtml, }; } } ================================================ FILE: src/helpers/UnpaddedBase64.ts ================================================ /** * Unpadded Base64 utilities for Matrix. * @category Utilities */ export class UnpaddedBase64 { private constructor() { } /** * Encodes a buffer to Unpadded Base64 * @param {Buffer} buf The buffer to encode. * @returns {string} The Unpadded Base64 string */ public static encodeBuffer(buf: Buffer): string { return buf.toString('base64').replace(/[=]*$/g, ''); } /** * Encodes a string to Unpadded Base64 * @param {string} str The string to encode. * @returns {string} The Unpadded Base64 string */ public static encodeString(str: string): string { return UnpaddedBase64.encodeBuffer(Buffer.from(str)); } /** * Encodes a buffer to Unpadded Base64 (URL Safe Edition) * @param {Buffer} buf The buffer to encode. * @returns {string} The Unpadded Base64 string */ public static encodeBufferUrlSafe(buf: Buffer): string { return UnpaddedBase64.encodeBuffer(buf).replace(/\+/g, '-').replace(/\//g, '_'); } /** * Encodes a string to Unpadded Base64 (URL Safe Edition) * @param {string} str The string to encode. * @returns {string} The Unpadded Base64 string */ public static encodeStringUrlSafe(str: string): string { return UnpaddedBase64.encodeBufferUrlSafe(Buffer.from(str)); } } ================================================ FILE: src/http.ts ================================================ import { LogLevel, LogService } from "./logging/LogService"; import { getRequestFn } from "./request"; import { MatrixError } from "./models/MatrixError"; let lastRequestId = 0; /** * Performs a web request to a server. * @category Unit testing * @param {string} baseUrl The base URL to apply to the call. * @param {"GET"|"POST"|"PUT"|"DELETE"} method The HTTP method to use in the request * @param {string} endpoint The endpoint to call. For example: "/_matrix/client/v3/account/whoami" * @param {any} qs The query string to send. Optional. * @param {any} body The request body to send. Optional. Will be converted to JSON unless the type is a Buffer. * @param {any} headers Additional headers to send in the request. * @param {number} timeout The number of milliseconds to wait before timing out. * @param {boolean} raw If true, the raw response will be returned instead of the response body. * @param {string} contentType The content type to send. Only used if the `body` is a Buffer. * @param {string} noEncoding Set to true to disable encoding, and return a Buffer. Defaults to false * @returns {Promise} Resolves to the response (body), rejected if a non-2xx status code was returned. */ export async function doHttpRequest( baseUrl: string, method: "GET" | "POST" | "PUT" | "DELETE", endpoint: string, qs = null, body = null, headers = {}, timeout = 60000, raw = false, contentType = "application/json", noEncoding = false, ): Promise { if (!endpoint.startsWith('/')) { endpoint = '/' + endpoint; } const requestId = ++lastRequestId; const url = baseUrl + endpoint; // This is logged at info so that when a request fails people can figure out which one. LogService.debug("MatrixHttpClient", "(REQ-" + requestId + ")", method + " " + url); // Don't log the request unless we're in debug mode. It can be large. if (LogService.level.includes(LogLevel.TRACE)) { if (qs) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "qs = " + JSON.stringify(qs)); if (body && !Buffer.isBuffer(body)) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "body = " + JSON.stringify(redactObjectForLogging(body))); if (body && Buffer.isBuffer(body)) LogService.trace("MatrixHttpClient", "(REQ-" + requestId + ")", "body = "); } const params: { uri: string, [k: string]: any } = { uri: url, method: method, qs: qs, // If this is undefined, then a string will be returned. If it's null, a Buffer will be returned. encoding: noEncoding === false ? undefined : null, useQuerystring: true, qsStringifyOptions: { options: { arrayFormat: 'repeat' }, }, timeout: timeout, headers: headers, // Enable KeepAlive for HTTP forever: true, }; if (body) { if (Buffer.isBuffer(body)) { params.headers["Content-Type"] = contentType; params.body = body; } else { params.headers["Content-Type"] = "application/json"; params.body = JSON.stringify(body); } } const { response, resBody } = await new Promise<{ response: any, resBody: any }>((resolve, reject) => { getRequestFn()(params, (err, res, rBody) => { if (err) { LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", err); reject(err); return; } if (typeof (rBody) === 'string') { try { rBody = JSON.parse(rBody); } catch (e) { } } if (typeof (res.body) === 'string') { try { res.body = JSON.parse(res.body); } catch (e) { } } resolve({ response: res, resBody: rBody }); }); }); const respIsBuffer = (response.body instanceof Buffer); // Check for errors. const errBody = response.body || resBody; if (typeof (errBody) === "object" && 'errcode' in errBody) { const redactedBody = respIsBuffer ? '' : redactObjectForLogging(errBody); LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody); throw new MatrixError(errBody, response.statusCode); } // Don't log the body unless we're in debug mode. They can be large. if (LogService.level.includes(LogLevel.TRACE)) { const redactedBody = respIsBuffer ? '' : redactObjectForLogging(response.body); LogService.trace("MatrixHttpClient", "(REQ-" + requestId + " RESP-H" + response.statusCode + ")", redactedBody); } if (response.statusCode < 200 || response.statusCode >= 300) { const redactedBody = respIsBuffer ? '' : redactObjectForLogging(response.body); LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody); throw response; } return raw ? response : resBody; } export function redactObjectForLogging(input: any): any { if (!input) return input; const fieldsToRedact = [ 'access_token', 'password', 'new_password', ]; const redactFn = (i) => { if (!i) return i; // Don't treat strings like arrays/objects if (typeof i === 'string') return i; if (Array.isArray(i)) { const rebuilt = []; for (const v of i) { rebuilt.push(redactFn(v)); } return rebuilt; } if (i instanceof Object) { const rebuilt = {}; for (const key of Object.keys(i)) { if (fieldsToRedact.includes(key)) { rebuilt[key] = ''; } else { rebuilt[key] = redactFn(i[key]); } } return rebuilt; } return i; // It's a primitive value }; return redactFn(input); } ================================================ FILE: src/identity/IdentityClient.ts ================================================ import * as crypto from "crypto"; import { OpenIDConnectToken } from "../models/OpenIDConnect"; import { doHttpRequest } from "../http"; import { timedIdentityClientFunctionCall } from "../metrics/decorators"; import { Policies, TranslatedPolicy } from "../models/Policies"; import { Metrics } from "../metrics/Metrics"; import { Threepid } from "../models/Threepid"; import { UnpaddedBase64 } from "../helpers/UnpaddedBase64"; import { MatrixClient } from "../MatrixClient"; import { MatrixProfile, MatrixProfileInfo } from "../models/MatrixProfile"; import { IdentityServerAccount, IdentityServerInvite } from "../models/IdentityServerModels"; /** * A way to access an Identity Server using the Identity Service API from a MatrixClient. * @category Identity Servers */ export class IdentityClient { /** * The metrics instance for this client. Note that metrics for the underlying MatrixClient will * not be available here. */ public readonly metrics: Metrics; /** * If truthy, this is a string that will be supplied as `?brand=$brand` where endpoints can * result in communications to a user. */ public brand: string; private constructor(public readonly accessToken: string, public readonly serverUrl: string, public readonly matrixClient: MatrixClient) { this.metrics = new Metrics(); } /** * Gets account information for the logged in user. * @returns {Promise} Resolves to the account information */ @timedIdentityClientFunctionCall() public getAccount(): Promise { return this.doRequest("GET", "/_matrix/identity/v2/account"); } /** * Gets the terms of service for which the identity server has. * @returns {Promise} Resolves to the policies of the server. */ @timedIdentityClientFunctionCall() public getTermsOfService(): Promise { return this.doRequest("GET", "/_matrix/identity/v2/terms"); } /** * Accepts a given set of URLs from Policy objects returned by the server. This implies acceptance of * the terms. Note that this will not update the user's account data to consider these terms accepted * in the future - that is an exercise left to the caller. * @param {string[]} termsUrls The URLs to count as accepted. * @returns {Promise} Resolves when complete. */ @timedIdentityClientFunctionCall() public acceptTerms(termsUrls: string[]): Promise { return this.doRequest("POST", "/_matrix/identity/v2/terms", null, { user_accepts: termsUrls, }); } /** * Accepts all the terms of service offered by the identity server. Note that this is only meant to be * used by automated bots where terms acceptance is implicit - the terms of service need to be presented * to the user in most cases. * @returns {Promise} Resolves when complete. */ @timedIdentityClientFunctionCall() public async acceptAllTerms(): Promise { const terms = await this.getTermsOfService(); const urls = new Set(); for (const policy of Object.values(terms.policies)) { let chosenLang = policy["en"] as TranslatedPolicy; if (!chosenLang) { chosenLang = policy[Object.keys(policy).find(k => k !== "version")] as TranslatedPolicy; } if (!chosenLang) continue; // skip - invalid urls.add(chosenLang.url); } return await this.acceptTerms(Array.from(urls)); } /** * Looks up a series of third party identifiers (email addresses or phone numbers) to see if they have * associated mappings. The returned array will be ordered the same as the input, and have falsey values * in place of any failed/missing lookups (eg: no mapping). * @param {Threepid[]} identifiers The identifiers to look up. * @param {boolean} allowPlaintext If true, the function will accept the server's offer to use plaintext * lookups when no other methods are available. The function will always prefer hashed methods. * @returns {Promise} Resolves to the user IDs (or falsey values) in the same order as the input. */ @timedIdentityClientFunctionCall() public async lookup(identifiers: Threepid[], allowPlaintext = false): Promise { const hashInfo = await this.doRequest("GET", "/_matrix/identity/v2/hash_details"); if (!hashInfo?.["algorithms"]) throw new Error("Server not supported: invalid response"); const algorithms = hashInfo?.["algorithms"]; let algorithm = algorithms.find(a => a === "sha256"); if (!algorithm && allowPlaintext) algorithm = algorithms.find(a => a === "none"); if (!algorithm) throw new Error("No supported hashing algorithm found"); const body = { algorithm, pepper: hashInfo["lookup_pepper"], addresses: [], }; for (const pid of identifiers) { let transformed = null; switch (algorithm) { case "none": transformed = `${pid.address.toLowerCase()} ${pid.kind}`; break; case "sha256": transformed = UnpaddedBase64.encodeBufferUrlSafe(crypto.createHash("sha256") .update(`${pid.address.toLowerCase()} ${pid.kind} ${body.pepper}`).digest()); break; default: throw new Error("Unsupported hashing algorithm (programming error)"); } body.addresses.push(transformed); } const resp = await this.doRequest("POST", "/_matrix/identity/v2/lookup", null, body); const mappings = resp?.["mappings"] || {}; const mxids: string[] = []; for (const addr of body.addresses) { mxids.push(mappings[addr]); } return mxids; } /** * Creates a third party email invite. This will store the invite in the identity server, but * not publish the invite to the room - the caller is expected to handle the remaining part. Note * that this function is not required to be called when using the Client-Server API for inviting * third party addresses to a room. This will make several calls into the room state to populate * the invite details, therefore the inviter (the client backing this identity client) must be * present in the room. * @param {string} emailAddress The email address to invite. * @param {string} roomId The room ID to invite to. * @returns {Promise} Resolves to the identity server's stored invite. */ @timedIdentityClientFunctionCall() public async makeEmailInvite(emailAddress: string, roomId: string): Promise { const req = { address: emailAddress, medium: "email", room_id: roomId, sender: await this.matrixClient.getUserId(), }; const tryFetch = async (eventType: string, stateKey: string): Promise => { try { return await this.matrixClient.getRoomStateEvent(roomId, eventType, stateKey); } catch (e) { return null; } }; const canonicalAlias = (await tryFetch("m.room.canonical_alias", ""))?.["alias"]; const roomName = (await tryFetch("m.room.name", ""))?.["name"]; req["room_alias"] = canonicalAlias; req["room_avatar_url"] = (await tryFetch("m.room.avatar", ""))?.["url"]; req["room_name"] = roomName || canonicalAlias; req["room_join_rules"] = (await tryFetch("m.room.join_rules", ""))?.["join_rule"]; let profileInfo: MatrixProfileInfo; try { profileInfo = await this.matrixClient.getUserProfile(await this.matrixClient.getUserId()); } catch (e) { // ignore } const senderProfile = new MatrixProfile(await this.matrixClient.getUserId(), profileInfo); req["sender_avatar_url"] = senderProfile.avatarUrl; req["sender_display_name"] = senderProfile.displayName; const inviteReq = {}; for (const entry of Object.entries(req)) { if (entry[1]) inviteReq[entry[0]] = entry[1]; } const qs = {}; if (this.brand) qs['brand'] = this.brand; return await this.doRequest("POST", "/_matrix/identity/v2/store-invite", qs, inviteReq); } /** * Performs a web request to the server, applying appropriate authorization headers for * this client. * @param {"GET"|"POST"|"PUT"|"DELETE"} method The HTTP method to use in the request * @param {string} endpoint The endpoint to call. For example: "/_matrix/identity/v2/account" * @param {any} qs The query string to send. Optional. * @param {any} body The request body to send. Optional. Will be converted to JSON unless the type is a Buffer. * @param {number} timeout The number of milliseconds to wait before timing out. * @param {boolean} raw If true, the raw response will be returned instead of the response body. * @param {string} contentType The content type to send. Only used if the `body` is a Buffer. * @param {string} noEncoding Set to true to disable encoding, and return a Buffer. Defaults to false * @returns {Promise} Resolves to the response (body), rejected if a non-2xx status code was returned. */ @timedIdentityClientFunctionCall() public doRequest(method, endpoint, qs = null, body = null, timeout = 60000, raw = false, contentType = "application/json", noEncoding = false): Promise { const headers = {}; if (this.accessToken) { headers["Authorization"] = `Bearer ${this.accessToken}`; } return doHttpRequest(this.serverUrl, method, endpoint, qs, body, headers, timeout, raw, contentType, noEncoding); } /** * Gets an instance of an identity client. * @param {OpenIDConnectToken} oidc The OpenID Connect token to register to the identity server with. * @param {string} serverUrl The full URL where the identity server can be reached at. */ public static async acquire(oidc: OpenIDConnectToken, serverUrl: string, mxClient: MatrixClient): Promise { const account = await doHttpRequest(serverUrl, "POST", "/_matrix/identity/v2/account/register", null, oidc); return new IdentityClient(account['token'], serverUrl, mxClient); } } ================================================ FILE: src/index.ts ================================================ // Appservices export * from "./appservice/Appservice"; export * from "./appservice/Intent"; export * from "./appservice/MatrixBridge"; export * from "./appservice/http_responses"; export * from "./appservice/UnstableAppserviceApis"; // Encryption export * from "./e2ee/RoomTracker"; export * from "./e2ee/CryptoClient"; export * from "./e2ee/decorators"; // export * from "./e2ee/RustEngine"; export * from "./e2ee/ICryptoRoomInformation"; // Helpers export * from "./helpers/RichReply"; export * from "./helpers/MentionPill"; export * from "./helpers/Permalinks"; export * from "./helpers/MatrixGlob"; export * from "./helpers/ProfileCache"; export * from "./helpers/MatrixEntity"; export * from "./helpers/UnpaddedBase64"; // Logging export * from "./logging/ConsoleLogger"; export * from "./logging/RichConsoleLogger"; export * from "./logging/ILogger"; export * from "./logging/LogService"; // Metrics export * from "./metrics/contexts"; export * from "./metrics/names"; export * from "./metrics/decorators"; export * from "./metrics/IMetricListener"; export * from "./metrics/Metrics"; // Mixins export * from "./mixins/AutojoinRoomsMixin"; export * from "./mixins/AutojoinUpgradedRoomsMixin"; // Models export * from "./models/Presence"; export * from "./models/MatrixProfile"; export * from "./models/EventContext"; export * from "./models/PowerLevelBounds"; export * from "./models/OpenIDConnect"; export * from "./models/Policies"; export * from "./models/Threepid"; export * from "./models/Spaces"; export * from "./models/IdentityServerModels"; export * from "./models/Crypto"; export * from "./models/MSC2176"; export * from "./models/Account"; export * from "./models/PowerLevelAction"; export * from "./models/ServerVersions"; export * from "./models/MatrixError"; export * from "./models/CreateRoom"; // Unstable models export * from "./models/unstable/MediaInfo"; // Event models export * from "./models/events/EventKind"; export * from "./models/events/converter"; export * from "./models/events/InvalidEventError"; export * from "./models/events/Event"; export * from "./models/events/RoomEvent"; export * from "./models/events/PresenceEvent"; export * from "./models/events/MembershipEvent"; export * from "./models/events/MessageEvent"; export * from "./models/events/AliasesEvent"; export * from "./models/events/CanonicalAliasEvent"; export * from "./models/events/CreateEvent"; export * from "./models/events/JoinRulesEvent"; export * from "./models/events/PowerLevelsEvent"; export * from "./models/events/RedactionEvent"; export * from "./models/events/PinnedEventsEvent"; export * from "./models/events/RoomAvatarEvent"; export * from "./models/events/RoomNameEvent"; export * from "./models/events/RoomTopicEvent"; export * from "./models/events/SpaceChildEvent"; export * from "./models/events/EncryptionEvent"; export * from "./models/events/EncryptedRoomEvent"; // Preprocessors export * from "./preprocessors/IPreprocessor"; export * from "./preprocessors/RichRepliesPreprocessor"; // Storage stuff export * from "./storage/IAppserviceStorageProvider"; export * from "./storage/IStorageProvider"; export * from "./storage/MemoryStorageProvider"; export * from "./storage/SimpleFsStorageProvider"; export * from "./storage/ICryptoStorageProvider"; export * from "./storage/RustSdkCryptoStorageProvider"; export * from "./storage/SimplePostgresStorageProvider"; // Strategies export * from "./strategies/AppserviceJoinRoomStrategy"; export * from "./strategies/JoinRoomStrategy"; // Other clients export * from "./identity/IdentityClient"; // Root-level stuff export * from "./IFilter"; export * from "./MatrixClient"; export * from "./MatrixAuth"; export * from "./UnstableApis"; export * from "./AdminApis"; export * from "./request"; export * from "./PantalaimonClient"; export * from "./SynchronousMatrixClient"; export * from "./SynapseAdminApis"; export * from "./simple-validation"; export * from "./b64"; export * from "./http"; export * from "./DMs"; ================================================ FILE: src/logging/ConsoleLogger.ts ================================================ import { ILogger } from "./ILogger"; /* eslint-disable no-console */ /** * Logs to the console in a plain format. This is the default logger. * @category Logging */ export class ConsoleLogger implements ILogger { public trace(module: string, ...messageOrObject: any[]) { console.trace(module, ...messageOrObject); } public debug(module: string, ...messageOrObject: any[]) { console.debug(module, ...messageOrObject); } public error(module: string, ...messageOrObject: any[]) { console.error(module, ...messageOrObject); } public info(module: string, ...messageOrObject: any[]) { console.log(module, ...messageOrObject); } public warn(module: string, ...messageOrObject: any[]) { console.warn(module, ...messageOrObject); } } /* eslint-enable no-console */ ================================================ FILE: src/logging/ILogger.ts ================================================ /** * Represents a logger * @category Logging */ export interface ILogger { /** * Logs to the INFO channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ info(module: string, ...messageOrObject: any[]); /** * Logs to the WARN channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ warn(module: string, ...messageOrObject: any[]); /** * Logs to the ERROR channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ error(module: string, ...messageOrObject: any[]); /** * Logs to the DEBUG channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ debug(module: string, ...messageOrObject: any[]); /** * Logs to the TRACE channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ trace(module: string, ...messageOrObject: any[]); } ================================================ FILE: src/logging/LogService.ts ================================================ import { MatrixError } from "../models/MatrixError"; import { ConsoleLogger } from "./ConsoleLogger"; import { ILogger } from "./ILogger"; /** * The log levels to log at. * @category Logging */ export class LogLevel { /** * The TRACE channel */ public static readonly TRACE = new LogLevel("TRACE", -1); /** * The DEBUG channel */ public static readonly DEBUG = new LogLevel("DEBUG", 0); /** * The INFO channel */ public static readonly INFO = new LogLevel("INFO", 1); /** * The WARN channel */ public static readonly WARN = new LogLevel("WARN", 2); /** * The ERROR channel */ public static readonly ERROR = new LogLevel("ERROR", 3); private constructor(private level: string, private sequence: number) { } public includes(level: LogLevel): boolean { return level.sequence >= this.sequence; } public toString(): string { return this.level; } public static fromString(level: string, defaultLevel = LogLevel.DEBUG): LogLevel { if (!level) return defaultLevel; if (level.toUpperCase() === LogLevel.TRACE.level) return LogLevel.TRACE; if (level.toUpperCase() === LogLevel.DEBUG.level) return LogLevel.DEBUG; if (level.toUpperCase() === LogLevel.INFO.level) return LogLevel.INFO; if (level.toUpperCase() === LogLevel.WARN.level) return LogLevel.WARN; if (level.toUpperCase() === LogLevel.ERROR.level) return LogLevel.ERROR; return defaultLevel; } } /** * Service class for logging in the bot-sdk * @category Logging */ export class LogService { private static logger: ILogger = new ConsoleLogger(); private static logLevel: LogLevel = LogLevel.INFO; private static mutedModules: string[] = []; private constructor() { } /** * The level at which the LogService is running. */ public static get level(): LogLevel { return this.logLevel; } /** * Sets the log level for this logger. Defaults to DEBUG. * @param {LogLevel} level the new log level */ public static setLevel(level: LogLevel) { LogService.logLevel = level || LogLevel.DEBUG; } /** * Sets a new logger for the Log Service * @param {ILogger} logger the new logger */ public static setLogger(logger: ILogger) { LogService.logger = logger; } /** * Mutes a module from the logger. * @param {string} name The module name to mute. */ public static muteModule(name: string) { LogService.mutedModules.push(name); } /** * Logs to the TRACE channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ public static trace(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.TRACE)) return; if (LogService.mutedModules.includes(module)) return; LogService.logger.trace(module, ...messageOrObject); } /** * Logs to the DEBUG channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ public static debug(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.DEBUG)) return; if (LogService.mutedModules.includes(module)) return; LogService.logger.debug(module, ...messageOrObject); } /** * Logs to the ERROR channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ public static error(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.ERROR)) return; if (LogService.mutedModules.includes(module)) return; LogService.logger.error(module, ...messageOrObject); } /** * Logs to the INFO channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ public static info(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.INFO)) return; if (LogService.mutedModules.includes(module)) return; LogService.logger.info(module, ...messageOrObject); } /** * Logs to the WARN channel * @param {string} module The module being logged * @param {any[]} messageOrObject The data to log */ public static warn(module: string, ...messageOrObject: any[]) { if (!LogService.logLevel.includes(LogLevel.WARN)) return; if (LogService.mutedModules.includes(module)) return; LogService.logger.warn(module, ...messageOrObject); } } /** * Extracts the useful part of a request's error into something loggable. * @param {Error} err The error to parse. * @returns {*} The extracted error, or the given error if unaltered. * @category Logging */ export function extractRequestError(err: Error | MatrixError): any { if (err && 'body' in err) { return err.body; } return err; } ================================================ FILE: src/logging/RichConsoleLogger.ts ================================================ import * as chalk from "chalk"; import { ILogger } from "./ILogger"; /* eslint-disable no-console */ /** * Prints to the console with colors and a format. * @category Logging */ export class RichConsoleLogger implements ILogger { protected chalkDebug = chalk.cyan; protected chalkInfo = chalk.green; protected chalkWarning = chalk.yellow; protected chalkError = chalk.bold.red; protected chalkTimestamp = chalk.grey; protected chalkModule = chalk.grey; protected getTimestamp(): string { const now = new Date(Date.now()).toUTCString(); return this.chalkTimestamp(now); } public trace(module: string, ...messageOrObject: any[]) { console.trace( this.getTimestamp(), this.chalkDebug("[TRACE]"), this.chalkModule(`[${module}]`), ...messageOrObject, ); } public debug(module: string, ...messageOrObject: any[]) { console.debug( this.getTimestamp(), this.chalkDebug("[DEBUG]"), this.chalkModule(`[${module}]`), ...messageOrObject, ); } public error(module: string, ...messageOrObject: any[]) { console.error( this.getTimestamp(), this.chalkError("[ERROR]"), this.chalkModule(`[${module}]`), ...messageOrObject, ); } public info(module: string, ...messageOrObject: any[]) { console.log( this.getTimestamp(), this.chalkInfo("[INFO]"), this.chalkModule(`[${module}]`), ...messageOrObject, ); } public warn(module: string, ...messageOrObject: any[]) { console.warn( this.getTimestamp(), this.chalkWarning("[WARN]"), this.chalkModule(`[${module}]`), ...messageOrObject, ); } } /* eslint-enable no-console */ ================================================ FILE: src/metrics/IMetricListener.ts ================================================ import { IMetricContext } from "./contexts"; /** * A simple interface for listening for Metric updates. Should be plugged into * something like Prometheus for recording. * * Metric names are defined in metric_names.ts - see documentation on the name * for what the context object contains. All metrics have a context object, * with applicable interface. See the IMetricContext interface for more * information. * @category Metrics */ export interface IMetricListener { /** * Called when the given metric should start being tracked. Will be * paired with a matching onEndMetric() call. * @param {string} metricName The metric being called. * @param {IMetricContext} context Context for the metric. Never null. */ onStartMetric(metricName: string, context: IMetricContext): void; /** * Called when the given metric should stop being tracked. Will have * started with a matching onStartMetric() call. * @param {string} metricName The metric being called. * @param {any} context Context for the metric. Never null. * @param {number} timeMs The measured time in milliseconds between * the start and end. */ onEndMetric(metricName: string, context: IMetricContext, timeMs: number): void; /** * Called when a linear metric (increasing/decreasing number) should * be incremented. * @param {string} metricName The metric being called. * @param {IMetricContext} context Context for the metric. Never null. * @param {number} amount The amount to add. Never negative or zero. */ onIncrement(metricName: string, context: IMetricContext, amount: number); /** * Called when a linear metric (increasing/decreasing number) should * be decremented. * @param {string} metricName The metric being called. * @param {IMetricContext} context Context for the metric. Never null. * @param {number} amount The amount to subtract. Never negative or zero. */ onDecrement(metricName: string, context: IMetricContext, amount: number); /** * Called when a linear metric (increasing/decreasing number) should * be reset to zero. * @param {string} metricName The metric being called. * @param {IMetricContext} context Context for the metric. Never null. */ onReset(metricName: string, context: IMetricContext); } ================================================ FILE: src/metrics/Metrics.ts ================================================ import { IMetricListener } from "./IMetricListener"; import { IMetricContext } from "./contexts"; import { LogService } from ".."; /** * Tracks metrics. * @category Metrics */ export class Metrics { private listeners: IMetricListener[] = []; private requestStartTimes: { [contextId: string]: number } = {}; private uid = 0; /** * Creates a new Metrics handler with optional parent handler. When * a parent handler is defined, metrics will be automatically published * upwards to the parent. * @param {Metrics} parent Optional parent for upstream metrics. */ constructor(parent: Metrics = null) { if (parent !== null) { this.registerListener({ onIncrement(metricName: string, context: IMetricContext, amount: number) { parent.listeners.forEach(h => h.onIncrement(metricName, context, amount)); }, onDecrement(metricName: string, context: IMetricContext, amount: number) { parent.listeners.forEach(h => h.onDecrement(metricName, context, amount)); }, onReset(metricName: string, context: IMetricContext) { parent.listeners.forEach(h => h.onReset(metricName, context)); }, onStartMetric(metricName: string, context: IMetricContext): void { parent.listeners.forEach(h => h.onStartMetric(metricName, context)); }, onEndMetric(metricName: string, context: IMetricContext, timeMs: number): void { parent.listeners.forEach(h => h.onEndMetric(metricName, context, timeMs)); }, }); } } /** * Registers a metric listener. * @param {IMetricListener} listener The listener. */ public registerListener(listener: IMetricListener) { this.listeners.push(listener); } /** * De-registers a metric listener. * @param {IMetricListener} listener The listener. */ public unregisterListener(listener: IMetricListener) { const idx = this.listeners.indexOf(listener); if (idx !== -1) this.listeners.splice(idx, 1); } /** * Starts a timer on a metric. * @param {string} metricName The metric name. * @param {IMetricContext} context The metric context. Expected to have a unique ID. */ public start(metricName: string, context: IMetricContext) { this.requestStartTimes[context.uniqueId] = new Date().getTime(); this.listeners.forEach(h => h.onStartMetric(metricName, context)); } /** * Ends a timer on a metric. * @param {string} metricName The metric name. * @param {IMetricContext} context The metric context. Expected to have a unique ID. */ public end(metricName: string, context: IMetricContext) { const timeMs = (new Date().getTime()) - this.requestStartTimes[context.uniqueId]; delete this.requestStartTimes[context.uniqueId]; this.listeners.forEach(h => h.onEndMetric(metricName, context, timeMs)); // Trim the context for logging const trimmedContext = {}; for (const key of Object.keys(context)) { if (key === 'client') { const client = context[key]; trimmedContext[key] = ``; } else if (key === 'intent') { const intent = context[key]; trimmedContext[key] = ``; } else { trimmedContext[key] = context[key]; } } LogService.trace("Metrics", metricName, trimmedContext, timeMs); } /** * Increments a metric. * @param {string} metricName The metric name. * @param {IMetricContext} context The metric context. Expected to have a unique ID. * @param {number} amount The amount. */ public increment(metricName: string, context: IMetricContext, amount: number) { this.listeners.forEach(h => h.onIncrement(metricName, context, amount)); } /** * Decrements a metric. * @param {string} metricName The metric name. * @param {IMetricContext} context The metric context. Expected to have a unique ID. * @param {number} amount The amount. */ public decrement(metricName: string, context: IMetricContext, amount: number) { this.listeners.forEach(h => h.onDecrement(metricName, context, amount)); } /** * Resets a metric. * @param {string} metricName The metric name. * @param {IMetricContext} context The metric context. Expected to have a unique ID. */ public reset(metricName: string, context: IMetricContext) { this.listeners.forEach(h => h.onReset(metricName, context)); } /** * Assigns a unique ID to the context object, returning it back. * @param {IMetricContext} context The context to modify. * @returns {IMetricContext} The provided context. */ public assignUniqueContextId(context: IMetricContext): IMetricContext { context.uniqueId = `${new Date().getTime()}-${this.uid++}`; return context; } } ================================================ FILE: src/metrics/contexts.ts ================================================ /** * Default context for all metrics. * @category Metrics */ import { MatrixClient } from "../MatrixClient"; import { Intent } from "../appservice/Intent"; import { IdentityClient } from "../identity/IdentityClient"; export interface IMetricContext { /** * Unique identifier for the context object. Used to differentiate * contexts over a start/end event. */ uniqueId: string; } /** * Metric context for function call metrics. * @category Metrics */ export interface FunctionCallContext extends IMetricContext { /** * The function name being called */ functionName: string; } /** * Metric context for metrics from a MatrixClient * @category Metrics */ export interface MatrixClientCallContext extends FunctionCallContext { /** * The client that raised the metric. */ client: MatrixClient; } /** * Metric context for metrics from an IdentityClient * @category Metrics */ export interface IdentityClientCallContext extends FunctionCallContext { client: IdentityClient; } /** * Metric context for metrics from an Intent * @category Metrics */ export interface IntentCallContext extends MatrixClientCallContext { /** * The intent that is raising the metric. */ intent: Intent; } ================================================ FILE: src/metrics/decorators.ts ================================================ import { METRIC_IDENTITY_CLIENT_FAILED_FUNCTION_CALL, METRIC_IDENTITY_CLIENT_FUNCTION_CALL, METRIC_IDENTITY_CLIENT_SUCCESSFUL_FUNCTION_CALL, METRIC_INTENT_FAILED_FUNCTION_CALL, METRIC_INTENT_FUNCTION_CALL, METRIC_INTENT_SUCCESSFUL_FUNCTION_CALL, METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL, METRIC_MATRIX_CLIENT_FUNCTION_CALL, METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL, } from "./names"; import { IdentityClientCallContext, IntentCallContext, MatrixClientCallContext } from "./contexts"; /** * Times a MatrixClient function call for metrics. * @category Metrics */ export function timedMatrixClientFunctionCall() { return function(_target: unknown, functionName: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function(...args) { const context = this.metrics.assignUniqueContextId({ functionName, client: this, }); this.metrics.start(METRIC_MATRIX_CLIENT_FUNCTION_CALL, context); try { const result = await originalMethod.apply(this, args); this.metrics.increment(METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL, context, 1); return result; } catch (e) { this.metrics.increment(METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL, context, 1); throw e; } finally { this.metrics.end(METRIC_MATRIX_CLIENT_FUNCTION_CALL, context); } }; }; } /** * Times an IdentityClient function call for metrics. * @category Metrics */ export function timedIdentityClientFunctionCall() { return function(_target: unknown, functionName: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function(...args: any[]) { const context = this.metrics.assignUniqueContextId({ functionName, client: this, }); this.metrics.start(METRIC_IDENTITY_CLIENT_FUNCTION_CALL, context); try { const result = await originalMethod.apply(this, args); this.metrics.increment(METRIC_IDENTITY_CLIENT_SUCCESSFUL_FUNCTION_CALL, context, 1); return result; } catch (e) { this.metrics.increment(METRIC_IDENTITY_CLIENT_FAILED_FUNCTION_CALL, context, 1); throw e; } finally { this.metrics.end(METRIC_IDENTITY_CLIENT_FUNCTION_CALL, context); } }; }; } /** * Times an Intent function call for metrics. * @category Metrics */ export function timedIntentFunctionCall() { return function(_target: unknown, functionName: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function(...args: any[]) { const context = this.metrics.assignUniqueContextId({ functionName, client: this.client, intent: this, }); this.metrics.start(METRIC_INTENT_FUNCTION_CALL, context); try { const result = await originalMethod.apply(this, args); this.metrics.increment(METRIC_INTENT_SUCCESSFUL_FUNCTION_CALL, context, 1); return result; } catch (e) { this.metrics.increment(METRIC_INTENT_FAILED_FUNCTION_CALL, context, 1); throw e; } finally { this.metrics.end(METRIC_INTENT_FUNCTION_CALL, context); } }; }; } ================================================ FILE: src/metrics/names.ts ================================================ /** * Time-series metric for how long a function call takes on MatrixClient. Uses a MatrixClientCallContext. * @category Metrics */ export const METRIC_MATRIX_CLIENT_FUNCTION_CALL = "matrix_client_function_call"; /** * Counter metric for failed function calls on a MatrixClient. Uses a MatrixClientCallContext. * @category Metrics */ export const METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL = "matrix_client_failed_function_call"; /** * Counter metric for successful function calls on a MatrixClient. Uses a MatrixClientCallContext. * @category Metrics */ export const METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL = "matrix_client_successful_function_call"; /** * Time-series metric for how long a function call takes on an IdentityClient. Uses an IdentityClientCallContext. * @category Metrics */ export const METRIC_IDENTITY_CLIENT_FUNCTION_CALL = "identity_client_function_call"; /** * Counter metric for failed function calls on an IdentityClient. Uses an IdentityClientCallContext. * @category Metrics */ export const METRIC_IDENTITY_CLIENT_FAILED_FUNCTION_CALL = "identity_client_failed_function_call"; /** * Counter metric for successful function calls on an IdentityClient. Uses an IdentityClientCallContext. * @category Metrics */ export const METRIC_IDENTITY_CLIENT_SUCCESSFUL_FUNCTION_CALL = "identity_client_successful_function_call"; /** * Time-series metric for how long a function call takes on an Intent. Uses a IntentCallContext. * @category Metrics */ export const METRIC_INTENT_FUNCTION_CALL = "intent_function_call"; /** * Counter metric for failed function calls on an Intent. Uses a IntentCallContext. * @category Metrics */ export const METRIC_INTENT_FAILED_FUNCTION_CALL = "intent_failed_function_call"; /** * Counter metric for successful function calls on an Intent. Uses a IntentCallContext. * @category Metrics */ export const METRIC_INTENT_SUCCESSFUL_FUNCTION_CALL = "intent_successful_function_call"; ================================================ FILE: src/mixins/AutojoinRoomsMixin.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { Appservice } from "../appservice/Appservice"; /** * Automatically accepts invites for rooms * @category Mixins */ export class AutojoinRoomsMixin { public static setupOnClient(client: MatrixClient): void { client.on("room.invite", (roomId: string, inviteEvent: any) => { return client.joinRoom(roomId); }); } public static setupOnAppservice(appservice: Appservice, conditional: (inviteEvent: any) => boolean = null): void { appservice.on("room.invite", (roomId: string, inviteEvent: any) => { const isFromBot = appservice.botUserId === inviteEvent["sender"]; if (!isFromBot && conditional && !conditional(inviteEvent)) return; const intent = appservice.getIntentForUserId(inviteEvent["state_key"]); return intent.joinRoom(roomId); }); } } ================================================ FILE: src/mixins/AutojoinUpgradedRoomsMixin.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { Appservice } from "../appservice/Appservice"; /** * Automatically tries to join upgraded rooms * @category Mixins */ export class AutojoinUpgradedRoomsMixin { public static setupOnClient(client: MatrixClient): void { client.on("room.archived", (roomId: string, tombstoneEvent: any) => { if (!tombstoneEvent['content']) return; if (!tombstoneEvent['sender']) return; if (!tombstoneEvent['content']['replacement_room']) return; const serverName = tombstoneEvent['sender'].split(':').splice(1).join(':'); return client.joinRoom(tombstoneEvent['content']['replacement_room'], [serverName]); }); } public static setupOnAppservice(appservice: Appservice): void { appservice.on("room.archived", async (roomId: string, tombstoneEvent: any) => { if (!tombstoneEvent['content']) return; if (!tombstoneEvent['sender']) return; if (!tombstoneEvent['content']['replacement_room']) return; const newRoomId = tombstoneEvent['content']['replacement_room']; const serverName = tombstoneEvent['sender'].split(':').splice(1).join(':'); const botClient = appservice.botIntent.underlyingClient; await botClient.joinRoom(newRoomId, [serverName]); const userIds = await botClient.getJoinedRoomMembers(roomId); const joinUserIds = userIds.filter(u => u !== appservice.botUserId && appservice.isNamespacedUser(u)); return await Promise.all(joinUserIds.map(u => appservice.getIntentForUserId(u).joinRoom(newRoomId))); }); } } ================================================ FILE: src/models/Account.ts ================================================ /** * Response to a `/whoami` request. * @category Models */ export interface IWhoAmI { user_id: string; device_id?: string; } ================================================ FILE: src/models/CreateRoom.ts ================================================ import { PowerLevelsEventContent } from "./events/PowerLevelsEvent"; import { CreateEventContent } from "./events/CreateEvent"; /** * "private_chat" sets: * - join_rules to `invite` * - history_visibility to `shared` * - guest_access to `can_join` * * "trusted_private_chat" sets: * - join_rules to `invite` * - history_visibility to `shared` * - guest_access to `can_join` * - All invitees are given the same power level as the room creator. * * "public_chat" sets: * - join_rules to `public` * - history_visibility to `shared` * - guest_access to `forbidden` * @category Models */ export type RoomPreset = "private_chat" | "trusted_private_chat" | "public_chat"; /** * "public" visibility indicates that the room will be shown in the published room list. * * "private" visibility indicates that the room will not be included in published room list. * @category Models */ export type RoomVisibility = "public" | "private"; /** * The options available when creating a room. * @category Models */ export interface RoomCreateOptions { /** * Extra keys, such as m.federate, to be added to the content of the m.room.create event. * The server will overwrite the following keys: `creator`, `room_version`. * Future versions of the specification may allow the server to overwrite other keys. */ creation_content?: Omit; /** * A list of state events to set in the new room. * This allows the user to override the default state events set in the new room. * The expected format of the state events are an object with `type`, `state_key` and `content` keys set. * Takes precedence over events set by `preset`, but gets overridden by `name` and `topic` keys. */ initial_state?: { /** * The content of the event. */ content: any; /** * The state_key of the state event. Defaults to an empty string. */ state_key?: string; /** * The type of event to send. */ type: string; }[]; /** * A list of user IDs to invite to the room. This will tell the server to invite everyone in the list to the newly created room. */ invite?: string[]; invite_3pid?: { /** * The invitee’s third party identifier. */ address: string; /** * An access token previously registered with the identity server. * Servers can treat this as optional to distinguish between r0.5-compatible clients and this specification version. */ id_access_token: string; /** * The hostname+port of the identity server which should be used for third party identifier lookups. */ id_server: string; /** * The kind of address being passed in the address field, for example `email`. */ medium: string; }[]; /** * This flag makes the server set the `is_direct` flag on the `m.room.member` events sent to the users in `invite` and `invite_3pid`. */ is_direct?: boolean; /** * If this is included, an `m.room.name` event will be sent into the room to indicate the name of the room. */ name?: string; /** * The power level content to override in the default power level event. * This object is applied on top of the generated `m.room.power_levels` event content prior to it being sent to the room. * Defaults to overriding nothing. */ power_level_content_override?: PowerLevelsEventContent; /** * Convenience parameter for setting various default state events based on a preset. * * If unspecified, the server should use the `visibility` to determine which preset to use. * A visbility of `public` equates to a preset of `public_chat` and `private` visibility equates to a preset of `private_chat`. */ preset?: RoomPreset; /** * The desired room alias local part. * If this is included, a room alias will be created and mapped to the newly created room. * The alias will belong on the same homeserver which created the room. */ room_alias_name?: string; /** * The room version to set for the room. * If not provided, the homeserver is to use its configured default. * If provided, the homeserver will return a `400` error with the errcode `M_UNSUPPORTED_ROOM_VERSION` if it does not support the room version. */ room_version?: string; /** * If this is included, an `m.room.topic` event will be sent into the room to indicate the topic for the room. */ topic?: string; /** * Sets the visibility of the room * Rooms default to private visibility if this key is not included. */ visibility?: RoomVisibility; } ================================================ FILE: src/models/Crypto.ts ================================================ /** * One time key algorithms. * @category Models */ export enum OTKAlgorithm { Signed = "signed_curve25519", Unsigned = "curve25519", } /** * Label for a one time key. * @category Models */ export type OTKLabel = `${Algorithm}:${ID}`; /** * Signatures object. * @category Models */ export interface Signatures { [entity: string]: { [keyLabel: string]: string; }; } /** * A signed_curve25519 one time key. * @category Models */ export interface SignedCurve25519OTK { key: string; signatures: Signatures; fallback?: boolean; } /** * A fallback key. * @category Models */ export interface FallbackKey { keyId: string; key: SignedCurve25519OTK & { fallback: true }; } /** * One Time Keys structure model. * @category Models */ export type OTKs = Record, SignedCurve25519OTK> & Record, string>; /** * The counts of each one time key by algorithm. * @category Models */ export type OTKCounts = { /** * The number of keys which remain unused for the algorithm. */ [alg in OTKAlgorithm]?: number; }; /** * The available encryption algorithms. * @category Models */ export enum EncryptionAlgorithm { OlmV1Curve25519AesSha2 = "m.olm.v1.curve25519-aes-sha2", MegolmV1AesSha2 = "m.megolm.v1.aes-sha2", } /** * The key algorithms for device keys. * @category Models */ export enum DeviceKeyAlgorithm { Ed25519 = "ed25519", Curve25519 = "curve25519", } /** * Label for a device key. * @category Models */ export type DeviceKeyLabel = `${Algorithm}:${ID}`; /** * Represents a user's device. * @category Models */ export interface UserDevice { user_id: string; device_id: string; algorithms: (EncryptionAlgorithm | string)[]; keys: Record, string>; signatures: Signatures; unsigned?: { [k: string]: any; device_display_name?: string; }; } /** * Represents a user's own device. * @category Models */ export interface OwnUserDevice { device_id: string; display_name?: string; last_seen_ip?: string; last_seen_ts?: number; } /** * Device list response for a multi-user query. * @category Models */ export interface MultiUserDeviceListResponse { /** * Federation failures, keyed by server name. The mapped object should be a standard * error object. */ failures: { [serverName: string]: any; }; /** * A map of user ID to device ID to device. */ device_keys: Record>; } /** * One Time Key claim response. * @category Models */ export interface OTKClaimResponse { /** * Federation failures, keyed by server name. The mapped object should be a standard * error object. */ failures: { [serverName: string]: any; }; /** * The claimed One Time Keys, as a map from user ID to device ID to key ID to OTK. */ one_time_keys: Record>; } /** * An encrypted Olm payload. * @category Models */ export interface IOlmEncrypted { algorithm: EncryptionAlgorithm.OlmV1Curve25519AesSha2; sender_key: string; ciphertext: { [deviceCurve25519Key: string]: { type: number; body: string; // base64 }; }; } /** * A to-device message. * @category Models */ export interface IToDeviceMessage { type: string; sender: string; content: T; } /** * Encrypted event content for a Megolm-encrypted m.room.encrypted event * @category Models */ export interface IMegolmEncrypted { algorithm: EncryptionAlgorithm.MegolmV1AesSha2; sender_key: string; ciphertext: string; session_id: string; device_id: string; // sender } ================================================ FILE: src/models/EventContext.ts ================================================ import { RoomEvent, RoomEventContent, StateEvent } from "./events/RoomEvent"; export interface EventContext { /** * The event that was used to build this context. */ event: RoomEvent; /** * The events that happened before the contextual event. */ before: RoomEvent[]; /** * The events that happened after the contextual event. */ after: RoomEvent[]; /** * The state of the room at the point of the last event returned. */ state: StateEvent[]; } ================================================ FILE: src/models/IdentityServerModels.ts ================================================ /** * Information about a user on an identity server. * @category Models */ export interface IdentityServerAccount { user_id: string; } /** * A stored invite on an identity server. * @category Models */ export interface IdentityServerInvite { display_name: string; public_keys: { public_key: string, key_validity_url: string }[]; // server key then ephemeral key, length of 2 public_key: string; token: string; } ================================================ FILE: src/models/MSC2176.ts ================================================ /** * Response object for a batch send operation. * @category Models */ export interface MSC2716BatchSendResponse { /** * List of historical state event IDs that were inserted */ state_events?: string[]; /** * List of historical event IDs that were inserted */ events?: string[]; /** * Chunk ID to be used in the next `sendHistoricalEventBatch` call. */ next_chunk_id: string; } // TODO: Verify docs and move to events subdirectory if appropriate /** * Partial event content for an inserted MSC2716 event. * @category Matrix event contents */ export interface MSC2716InsertionEventContent { "org.matrix.msc2716.next_chunk_id": string; "org.matrix.msc2716.historical": true; } /** * Partial event content for a chunked MSC2716 event. * @category Matrix event contents */ export interface MSC2716ChunkEventContent { "org.matrix.msc2716.chunk_id": string; "org.matrix.msc2716.historical": true; } /** * Partial event content for a marked MSC2716 event. * @category Matrix event contents */ export interface MSC2716MarkerEventContent { "org.matrix.msc2716.insertion_id": string; "org.matrix.msc2716.historical": true; } ================================================ FILE: src/models/MatrixError.ts ================================================ /** * Represents an HTTP error from the Matrix server. * @category Error handling */ export class MatrixError extends Error { /** * The Matrix error code */ public readonly errcode: string; /** * Optional human-readable error message. */ public readonly error: string; /** * If rate limited, the time in milliseconds to wait before retrying the request */ public readonly retryAfterMs?: number; /** * Creates a new Matrix Error * @param body The error body. * @param statusCode The HTTP status code. */ constructor(public readonly body: { errcode: string, error: string, retry_after_ms?: number }, public readonly statusCode: number) { super(); this.errcode = body.errcode; this.error = body.error; this.retryAfterMs = body.retry_after_ms; } /** * Developer-friendly error message. */ public get message() { return `${this.errcode}: ${this.error}`; } } ================================================ FILE: src/models/MatrixProfile.ts ================================================ import { MentionPill, UserID } from ".."; /** * Profile information commonly associated with Matrix profiles * @category Models */ export interface MatrixProfileInfo { /** * The display name of the user, if any. */ displayname?: string; /** * A URL to the user's avatar, if any. */ avatar_url?: string; } /** * Represents a user's profile, possibly in a room. * @category Models */ export class MatrixProfile { /** * Creates a new profile representation for a user. * @param {string} userId The user ID the profile is for. * @param {MatrixProfile} profile The profile itself. */ constructor(private userId: string, private profile: MatrixProfileInfo) { } /** * The display name for the user. This will always return a value, though it * may be based upon their user ID if no explicit display name is set. */ public get displayName(): string { if (!this.profile?.displayname) return new UserID(this.userId).localpart; return this.profile.displayname; } /** * The avatar URL for the user. If the user does not have an avatar, this will * be null. */ public get avatarUrl(): string { return this.profile?.avatar_url || null; // enforce null over boolean semantics } /** * A mention pill for this user. */ public get mention(): MentionPill { return MentionPill.withDisplayName(this.userId, this.displayName); } } ================================================ FILE: src/models/OpenIDConnect.ts ================================================ /** * An OpenID Connect token from the homeserver. * @category Models */ export interface OpenIDConnectToken { access_token: string; expires_in: number; matrix_server_name: string; token_type: 'Bearer'; } ================================================ FILE: src/models/Policies.ts ================================================ /** * Information about the policies (terms of service) a server may have. * @category Models */ export interface Policies { policies: { [id: string]: Policy; }; } /** * Information about a policy (terms of service) a server may have. * @category Models */ export interface Policy { version: string; [language: string]: TranslatedPolicy | string; // "|string" is required for `version` to work } /** * Information about a (translated) policy (terms of service). * @category Models */ export interface TranslatedPolicy { name: string; url: string; } ================================================ FILE: src/models/PowerLevelAction.ts ================================================ /** * Actions that can be guarded by power levels. */ export enum PowerLevelAction { /** * Power level required to ban other users. */ Ban = "ban", /** * Power level required to kick other users. */ Kick = "kick", /** * Power level required to redact events sent by other users. Users can redact * their own messages regardless of this power level requirement, unless forbidden * by the `events` section of the power levels content. */ RedactEvents = "redact", /** * Power level required to invite other users. */ Invite = "invite", /** * Power level required to notify the whole room with "@room". */ NotifyRoom = "notifications.room", } ================================================ FILE: src/models/PowerLevelBounds.ts ================================================ /** * Information on the bounds of a power level change a user can apply. */ export interface PowerLevelBounds { /** * Whether or not the user can even modify the power level of the user. This * will be false if the user can't send power level events, or the user is * unobtainably high in power. */ canModify: boolean; /** * The maximum possible power level the user can set on the target user. */ maximumPossibleLevel: number; } ================================================ FILE: src/models/Presence.ts ================================================ import { PresenceEventContent, PresenceState } from "./events/PresenceEvent"; /** * Presence information for a user. * @category Models */ export class Presence { constructor(protected presence: PresenceEventContent) { } /** * The state for this presence update. */ public get state(): PresenceState { return this.presence.presence; } /** * The status message which accompanies this presence. May be falsey. */ public get statusMessage(): string { return this.presence.status_msg; } /** * How long ago in milliseconds this presence was changed. May be falsey. */ public get lastActiveAgo(): number { return this.presence.last_active_ago; } /** * Whether or not the user is currently active. */ public get currentlyActive(): boolean { return this.presence.currently_active; } } ================================================ FILE: src/models/ServerVersions.ts ================================================ /** * Representation of the server's supported specification versions and unstable feature flags. * @category Models */ export type ServerVersions = { unstable_features?: Record; versions: string[]; }; ================================================ FILE: src/models/Spaces.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { UserID } from "../helpers/MatrixEntity"; import { validateSpaceOrderString } from "../simple-validation"; import { SpaceChildEvent, SpaceChildEventContent } from "./events/SpaceChildEvent"; /** * Options to be specified when creating a Space. * @category Models */ export interface SpaceCreateOptions { /** * The name of the space. */ name: string; /** * The topic/description for the space. */ topic?: string; /** * An MXC URI for the space's avatar. */ avatarUrl?: string; /** * Whether or not the space should be publicly joinable or not. */ isPublic: boolean; // maps to world_readable on true, otherwise private. /** * Optional localpart for the alias of the space. */ localpart?: string; /** * User IDs to invite to the space upon creation. */ invites?: string[]; } /** * Options for displaying/handling a child room/space. * @category Models */ export interface SpaceChildEntityOptions { /** * Whether or not the entity is intended to be a suggested entity. */ suggested?: boolean; /** * Servers to try and join through. When not provided, the SDK will try to * determine a set. */ via?: string[]; /** * A short string to differentiate the rendering order of entities. * @see validateSpaceOrderString */ order?: string; } /** * Options for creating a new child space or room. * @category Models */ export type NewChildOpts = SpaceCreateOptions & SpaceChildEntityOptions; /** * A mapping of room ID to space child information. * @category Models */ export interface SpaceEntityMap { [roomId: string]: SpaceChildEvent; } /** * An instance representing a Matrix Space. A space is tied to a room. * @category Models */ export class Space { public constructor(public readonly roomId: string, public readonly client: MatrixClient) { } /** * Creates a new child space under this space. * @param {SpaceCreateOptions} opts The options for the new space. * @returns {Promise} Resolves to the created space. */ public async createChildSpace(opts: NewChildOpts): Promise { const space = await this.client.createSpace(opts); await this.addChildSpace(space, opts); return space; } /** * Adds a child space to the space. Must be joined to both spaces. * @param {Space} space The space to add. * @param {SpaceChildEntityOptions} childOpts Related options for the child's representation. * @returns {Promise} Resolves when complete. */ public async addChildSpace(space: Space, childOpts: SpaceChildEntityOptions = {}): Promise { await this.addChildRoom(space.roomId, childOpts); } /** * Removes a child space from the space. Must be joined to the current space (not needed for child space). * @param {Space} space The space to remove. * @returns {Promise} Resolves when complete. */ public async removeChildSpace(space: Space): Promise { await this.removeChildRoom(space.roomId); } /** * Adds a child room to the space. Must be joined to both the room and the space. * @param {string} roomId The room ID to add. * @param {SpaceChildEntityOptions} childOpts Additional options for the child space. * @returns {Promise} Resolves when complete. */ public async addChildRoom(roomId: string, childOpts: SpaceChildEntityOptions = {}): Promise { const via = childOpts.via ?? [new UserID(await this.client.getUserId()).domain]; const childContent: SpaceChildEventContent = { via }; if (childOpts.suggested) childContent.suggested = childOpts.suggested; if (childOpts.order) { validateSpaceOrderString(childOpts.order); childContent.order = childOpts.order; } await this.client.sendStateEvent(this.roomId, "m.space.child", roomId, childContent); } /** * Removes a child room from the space. Must be joined to the current space (not needed for child room). * @param {string} roomId The room ID to remove. * @returns {Promise} Resolves when complete. */ public async removeChildRoom(roomId: string): Promise { await this.client.sendStateEvent(this.roomId, "m.space.child", roomId, {}); } /** * Gets all the child rooms on the space. These may be spaces or other rooms. * @returns {Promise} Resolves to a map of children for this space. */ public async getChildEntities(): Promise { const roomState = await this.client.getRoomState(this.roomId); const mapping: SpaceEntityMap = {}; roomState .filter(s => s.type === "m.space.child") .filter(s => s.content?.via) .forEach(s => mapping[s.state_key] = new SpaceChildEvent(s)); return mapping; } /** * Invite a user to the current space. * @param {string} userId The user ID to invite. * @returns {Promise} Resolves when completed. */ public async inviteUser(userId: string) { return this.client.inviteUser(userId, this.roomId); } } ================================================ FILE: src/models/Threepid.ts ================================================ /** * A Third Party Identifier (3PID or threepid) * @category Models */ export interface Threepid { kind: "email" | "msisdn" | string; address: string; } ================================================ FILE: src/models/events/AliasesEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.aliases events * @category Matrix event contents * @see AliasesEvent */ export interface AliasesEventContent { /** * The aliases this domain has published to the room. */ aliases: string[]; } /** * Represents an m.room.aliases state event * @category Matrix events */ export class AliasesEvent extends StateEvent { constructor(event: any) { super(event); } /** * The domain the aliases belong to. */ public get forDomain(): string { return this.stateKey; } /** * The aliases the domain has published to the room. */ public get aliases(): string[] { return this.content.aliases || []; } } ================================================ FILE: src/models/events/CanonicalAliasEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.canonical_alias events * @category Matrix event contents * @see CanonicalAliasEvent */ export interface CanonicalAliasEventContent { /** * The canonical alias for the room. */ alias: string; } /** * Represents an m.room.canonical_alias state event * @category Matrix events */ export class CanonicalAliasEvent extends StateEvent { constructor(event: any) { super(event); } /** * The alias the room is considering canonical */ public get aliases(): string { return this.content.alias; } } ================================================ FILE: src/models/events/CreateEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * Information about the previous room. * @category Matrix event info * @see CreateEventContent */ export interface PreviousRoomInfo { /** * The old room ID. */ room_id: string; /** * The last known event ID in the old room. * Optional only for room versions >=12. */ event_id?: string; } /** * The content definition for m.room.create events * @category Matrix event contents * @see CreateEvent */ export interface CreateEventContent extends Record { /** * The user ID who created the room. */ creator: string; /** * Whether or not this room is federated. Default true. */ "m.federate"?: boolean; /** * The version of the room. Default "1". */ room_version?: string; /** * Information about the old room. */ predecessor?: PreviousRoomInfo; /** * The type of the room, if applicable. For example, `m.space`. */ type?: string; } /** * Represents an m.room.create state event * @category Matrix events */ export class CreateEvent extends StateEvent { constructor(event: any) { super(event); } /** * The user ID who created the room. */ public get creator(): string { return this.content.creator || this.sender; } /** * The version of the room. Defaults to "1". */ public get version(): string { return this.content.room_version || "1"; } /** * Whether or not the room is federated. Default true (federated). */ public get federated(): boolean { return this.content['m.federate'] !== false; } } ================================================ FILE: src/models/events/EncryptedRoomEvent.ts ================================================ import { RoomEvent } from "./RoomEvent"; import { EncryptionAlgorithm, IMegolmEncrypted } from "../Crypto"; /** * The content definition for m.room.encrypted events * @category Matrix event contents * @see EncryptedRoomEvent */ export interface EncryptedRoomEventContent { algorithm: EncryptionAlgorithm; /** * For m.megolm.v1.aes-sha2 messages. The sender's Curve25519 key. */ sender_key?: string; /** * For m.megolm.v1.aes-sha2 messages. The session ID established by the sender. */ session_id?: string; /** * For m.megolm.v1.aes-sha2 messages. The encrypted payload. */ ciphertext?: string; /** * For m.megolm.v1.aes-sha2 messages. The sender's device ID. */ device_id?: string; // Other algorithms not supported at the moment } /** * Represents an m.room.encrypted room event * @category Matrix events */ export class EncryptedRoomEvent extends RoomEvent { constructor(event: any) { super(event); } /** * The encryption algorithm used on the event. Should match the m.room.encryption * state config. */ public get algorithm(): EncryptionAlgorithm { return this.content.algorithm; } /** * The Megolm encrypted payload information. */ public get megolmProperties(): IMegolmEncrypted { return this.content as IMegolmEncrypted; } } ================================================ FILE: src/models/events/EncryptionEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The kinds of room encryption algorithms allowed by the spec. * @category Models * @see EncryptionEvent */ export enum RoomEncryptionAlgorithm { MegolmV1AesSha2 = "m.megolm.v1.aes-sha2", } /** * The content definition for m.room.encryption events * @category Matrix event contents * @see EncryptionEvent */ export interface EncryptionEventContent { /** * The encryption algorithm for the room. */ algorithm: string | RoomEncryptionAlgorithm; /** * How long a session should be used before changing it. */ rotation_period_ms?: number; /** * How many messages should be sent before changing the session. */ rotation_period_msgs?: number; } /** * Represents an m.room.encryption state event * @category Matrix events */ export class EncryptionEvent extends StateEvent { constructor(event: any) { super(event); } /** * The encryption algorithm for the room. */ public get algorithm(): string | RoomEncryptionAlgorithm { return this.content.algorithm; } /** * How long a session should be used before changing it. Defaults to a week. */ public get rotationPeriodMs(): number { return this.content.rotation_period_ms ?? 604800000; // 1 week } /** * How many messages should be sent before a session changes. Defaults to 100. */ public get rotationPeriodMessages(): number { return this.content.rotation_period_msgs ?? 100; } } ================================================ FILE: src/models/events/Event.ts ================================================ /** * A Matrix event. * @category Matrix events */ export class MatrixEvent { constructor(protected event: any) { } /** * The user ID who sent this event. */ public get sender(): string { return this.event['sender']; } /** * The type of this event. */ public get type(): string { return this.event['type']; } /** * The content for this event. May have no properties. */ public get content(): T { return this.event['content'] || {}; } /** * Gets the raw event that this MatrixEvent is using. * Note that there's no guarantees on formats here - it is the exact * same input to the constructor. */ public get raw(): any { return this.event; } } ================================================ FILE: src/models/events/EventKind.ts ================================================ /** * Represents the different kinds of events a bot/appservice might see. * @category Matrix events */ export enum EventKind { /** * A room event. This could be a message event or a state event, and is associated with * a room. */ RoomEvent = "room", /** * An ephemeral event, such as typing notifications or presence. */ EphemeralEvent = "ephemeral", } ================================================ FILE: src/models/events/InvalidEventError.ts ================================================ /** * Thrown when an event is invalid. * @category Matrix events */ export class InvalidEventError extends Error { constructor(message: string = null) { super(message); } } /** * Thrown when an event is redacted. * @category Matrix events */ export class EventRedactedError extends InvalidEventError { constructor(message: string = null) { super(message); } } ================================================ FILE: src/models/events/JoinRulesEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The types of join rules that are valid in Matrix. * @category Matrix event info * @see JoinRulesEventContent */ export type JoinRule = "public" | "knock" | "invite" | "private"; /** * The content definition for m.room.join_rules events * @category Matrix event contents * @see JoinRulesEvent */ export interface JoinRulesEventContent { /** * The join rule for the room. */ join_rule: JoinRule; } /** * Represents an m.room.join_rules state event * @category Matrix events */ export class JoinRulesEvent extends StateEvent { constructor(event: any) { super(event); } /** * The join rule for the room. */ public get rule(): JoinRule { return this.content.join_rule; } } ================================================ FILE: src/models/events/MembershipEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; import { InvalidEventError } from "./InvalidEventError"; /** * The types of membership that are valid in Matrix. * @category Matrix event info * @see MembershipEventContent */ export type Membership = "join" | "leave" | "ban" | "invite"; /** * The effective membership states a user can be in. * @category Matrix event info * @see MembershipEventContent */ export type EffectiveMembership = "join" | "leave" | "invite"; /** * The content definition for m.room.member events * @category Matrix event contents * @see MembershipEvent */ export interface MembershipEventContent { avatar_url?: string; displayname?: string; membership: Membership; is_direct?: boolean; unsigned?: any; third_party_invite?: { display_name: string; signed: any; }; } /** * Represents an m.room.member state event * @category Matrix events */ export class MembershipEvent extends StateEvent { constructor(event: any) { super(event); } /** * True if the membership event targets the sender. False otherwise. * * This will typically by false for kicks and bans. */ public get ownMembership(): boolean { return this.membershipFor === this.sender; } /** * The user ID the membership affects. */ public get membershipFor(): string { return this.stateKey; } /** * The user's membership. */ public get membership(): Membership { const membership = this.content.membership; if (!membership) throw new InvalidEventError("no membership field in content"); return membership; } /** * The user's effective membership. */ public get effectiveMembership(): EffectiveMembership { if (this.membership === "join") return "join"; if (this.membership === "invite") return "invite"; return "leave"; } } ================================================ FILE: src/models/events/MessageEvent.ts ================================================ import { RoomEvent } from "./RoomEvent"; import { EventRedactedError } from "./InvalidEventError"; /** * The types of messages that are valid in Matrix. * @category Matrix event info * @see MessageEventContent */ export type MessageType = "m.text" | "m.emote" | "m.notice" | "m.image" | "m.file" | "m.audio" | "m.location" | "m.video" | string; /** * Information about a file in Matrix * @category Matrix event info * @see MessageEventContent */ export interface FileInfo { /** * The size of the file in bytes. */ size?: number; /** * The type of file. */ mimetype?: string; } /** * Information about a thumbnail in Matrix * @category Matrix event info * @see MessageEventContent */ export interface ThumbnailInfo { /** * The size of the thumbnail in bytes. */ size?: number; /** * The type of thumbnail. */ mimetype?: string; /** * The intended height of the thumbnail in pixels. */ h: number; /** * The intended width of the thumbnail in pixels. */ w: number; } /** * Information about a file's thumbnail. * @category Matrix event info * @see MessageEventContent */ export interface ThumbnailedFileInfo { /** * A URL to a thumbnail for the file, if unencrypted. */ thumbnail_url?: string; /** * The encrypted thumbnail file information, if encrypted. */ thumbnail_file?: EncryptedFile; /** * Information about the thumbnail. Optionally included if a thumbnail_url is specified. */ thumbnail_info?: ThumbnailInfo; } /** * Information about a file that has a thumbnail * @category Matrix event info * @see MessageEventContent */ export interface FileWithThumbnailInfo extends FileInfo, ThumbnailedFileInfo { } /** * Information about a file that has a width and height. * @category Matrix event info * @see MessageEventContent */ export interface DimensionalFileInfo extends FileWithThumbnailInfo { /** * The intended height of the media in pixels. */ h: number; /** * The intended width of the media in pixels. */ w: number; } /** * Information about a file that has a time dimension. * @category Matrix event info * @see MessageEventContent */ export interface TimedFileInfo extends FileInfo { /** * The duration of the media in milliseconds. */ duration: number; } /** * Information about a video file. * @category Matrix event info * @see MessageEventContent */ export interface VideoFileInfo extends DimensionalFileInfo, TimedFileInfo { // No new properties. } /** * The content definition for m.room.message events with a type of m.audio * @category Matrix event contents * @see MessageEvent */ export interface AudioMessageEventContent extends FileMessageEventContent { /** * Information about the file. */ info?: TimedFileInfo; } /** * The content definition for m.room.message events with a type of m.video * @category Matrix event contents * @see MessageEvent */ export interface VideoMessageEventContent extends FileMessageEventContent { /** * Information about the file. */ info?: VideoFileInfo; } /** * The content definition for m.room.message events with a type of m.image * @category Matrix event contents * @see MessageEvent */ export interface ImageMessageEventContent extends FileMessageEventContent { /** * Information about the file. */ info?: DimensionalFileInfo; } /** * The content definition for m.room.message events with a type of m.file * @category Matrix event contents * @see MessageEvent */ export interface FileMessageEventContent extends MessageEventContent { /** * Information about the file. */ info?: FileWithThumbnailInfo; /** * URL to the file, if unencrypted. */ url: string; /** * The encrypted file, if encrypted. */ file: EncryptedFile; } /** * An encrypted file. * @category Matrix event contents * @see MessageEvent */ export interface EncryptedFile { url: string; key: { kty: "oct"; key_ops: string[]; alg: "A256CTR"; k: string; ext: true; }; iv: string; hashes: { sha256: string; }; v: "v2"; } /** * The content definition for m.room.message events with a type of m.location * @category Matrix event contents * @see MessageEvent */ export interface LocationMessageEventContent extends MessageEventContent { /** * Information about the location. */ info?: ThumbnailedFileInfo; /** * A geo URI of the location. */ geo_uri?: string; } /** * The content definition for m.room.message events with types of m.text, m.emote, and m.notice * @category Matrix event contents * @see MessageEvent */ export interface TextualMessageEventContent extends MessageEventContent { format?: string; formatted_body?: string; } /** * The content definition for m.room.message events * @category Matrix event contents * @see MessageEvent */ export interface MessageEventContent { body: string; msgtype: MessageType; external_url?: string; } /** * Represents an m.room.message room event * @category Matrix events */ export class MessageEvent extends RoomEvent { constructor(event: any) { super(event); } /** * Whether or not the event is redacted (or looked redacted). */ public get isRedacted(): boolean { // Presume the event redacted if we're missing a body or message type const noContent = !this.content.body && this.content.body !== ""; const noMsgtype = !this.content.msgtype && this.content.msgtype !== ""; return noContent || noMsgtype; } /** * The message's type. */ public get messageType(): MessageType { const type = this.content.msgtype; if (!type && type !== "") throw new EventRedactedError("missing msgtype"); return type; } /** * The `body` of the message. */ public get textBody(): string { const body = this.content.body; if (!body && body !== "") throw new EventRedactedError("missing body"); return body; } /** * The `external_url` of the message, if it exists */ public get externalUrl(): string | undefined { return this.content.external_url || undefined; } } ================================================ FILE: src/models/events/PinnedEventsEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.pinned_events events * @category Matrix event contents * @see PinnedEventsEvent */ export interface PinnedEventsEventContent { /** * The event IDs that are pinned in the room. */ pinned: string[]; } /** * Represents an m.room.pinned_events state event * @category Matrix events */ export class PinnedEventsEvent extends StateEvent { constructor(event: any) { super(event); } /** * The event IDs that are pinned in the room. */ public get pinnedEventIds(): string[] { return this.content.pinned || []; } } ================================================ FILE: src/models/events/PowerLevelsEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.power_levels events * @category Matrix event contents * @see PowerLevelsEvent */ export interface PowerLevelsEventContent { /** * The power level required to ban. Default 50. */ ban?: number; /** * A map of event types to the power level required to send them. */ events?: { [eventType: string]: number }; /** * The power level required to send events in the room. Default 50. */ events_default?: number; /** * The power level required to invite users to the room. Default 50. */ invite?: number; /** * The power level required to kick users from the room. Default 50. */ kick?: number; /** * The power level required to redact other people's events in the room. Default 50. */ redact?: number; /** * The power level required to send state events in the room. Default 50. */ state_default?: number; /** * A map of user IDs to power levels. */ users?: { [userId: string]: number }; /** * The power level of users not listed in `users`. Default 0. */ users_default?: number; /** * Power levels required to send certain kinds of notifications. */ notifications?: { /** * The power level required to send "@room" notifications. Default 50. */ room?: number; }; } function defaultNum(val: number | undefined, def: number): number { if (!val && val !== 0) return def; return val; } /** * Represents an m.room.power_levels state event * @category Matrix events */ export class PowerLevelsEvent extends StateEvent { constructor(event: any) { super(event); } /** * The power level required to ban users. */ public get banLevel(): number { return defaultNum(this.content.ban, 50); } /** * The power level required to invite users. */ public get inviteLevel(): number { return defaultNum(this.content.invite, 50); } /** * The power level required to kick users. */ public get kickLevel(): number { return defaultNum(this.content.kick, 50); } /** * The power level required to redact messages sent by other users. */ public get redactLevel(): number { return defaultNum(this.content.redact, 50); } /** * The power level required to send "@room" notifications. */ public get notifyWholeRoomLevel(): number { if (!this.content.notifications) return 50; return defaultNum(this.content.notifications.room, 50); } /** * The default power level for users. */ public get defaultUserLevel(): number { return defaultNum(this.content.users_default, 0); } /** * The default power level required to send state events. */ public get defaultStateEventLevel(): number { return defaultNum(this.content.state_default, 50); } /** * The default power level required to send room events. */ public get defaultEventLevel(): number { return defaultNum(this.content.events_default, 50); } } ================================================ FILE: src/models/events/PresenceEvent.ts ================================================ import { MatrixEvent } from "./Event"; /** * The allowed states of presence in Matrix. * * * `online`: The default state when the user is connected to an event stream. * * `unavailable`: The user is not reachable at this time e.g. they are idle. * * `offline`: The user is not connected to an event stream or is explicitly suppressing their profile information from being sent. * * @category Matrix event info * @see PresenceEventContent */ export type PresenceState = "online" | "offline" | "unavailable"; /** * Event content for m.presence events * @category Matrix event contents * @see PresenceEvent */ export interface PresenceEventContent { /** * The avatar URL for the user, if any. */ avatar_url?: string; /** * The display name for the user, if any. */ displayname?: string; /** * How long ago the user performed some action, in milliseconds. */ last_active_ago?: number; /** * The user's presence state. * * @see {@link PresenceState} for a description of each presence key. */ presence: PresenceState; /** * Whether or not the user is currently active. */ currently_active?: boolean; /** * A status message associated with this presence. */ status_msg?: string; } /** * Wraps a m.presence ephemeral event in Matrix * @category Matrix events */ export class PresenceEvent extends MatrixEvent { constructor(event: any) { super(event); } /** * The current presence state for the user. */ public get presence(): PresenceState { return this.content.presence; } } ================================================ FILE: src/models/events/RedactionEvent.ts ================================================ import { RoomEvent } from "./RoomEvent"; /** * The content definition for m.room.redaction events * @category Matrix event contents * @see RedactionEvent */ export interface RedactionEventContent { /** * The event ID or IDs this event redacts. */ redacts?: string | string[]; // MSC2174 & MSC2244 } /** * Represents an m.room.redaction room event * @category Matrix events */ export class RedactionEvent extends RoomEvent { constructor(event: any) { super(event); } /** * The event ID this event redacts. * @deprecated It is possible for multiple events to be redacted depending on the room version. */ public get redactsEventId(): string { return this.redactsEventIds[0]; } /** * The event IDs this event redacts. */ public get redactsEventIds(): string[] { if (Array.isArray(this.content.redacts)) { return this.content.redacts; } else if (this.content.redacts) { return [this.content.redacts]; } else { return [this.event['redacts']]; } } } ================================================ FILE: src/models/events/RoomAvatarEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; import { DimensionalFileInfo } from "./MessageEvent"; /** * The content definition for m.room.avatar events * @category Matrix event contents * @see RoomAvatarEvent */ export interface RoomAvatarEventContent { /** * The URL to the image for the avatar of the room. */ url: string; /** * Optional information about the avatar. */ info?: DimensionalFileInfo; } /** * Represents an m.room.avatar state event * @category Matrix events */ export class RoomAvatarEvent extends StateEvent { constructor(event: any) { super(event); } /** * The URL for the avatar of the room. */ public get avatarUrl(): string { return this.content.url; } } ================================================ FILE: src/models/events/RoomEvent.ts ================================================ import { MatrixEvent } from "./Event"; /** * The typical unsigned data found on an event. * @category Matrix event info * @see RoomEvent */ export interface TypicalUnsigned { /** * The age of this event in seconds. */ age?: number; /** * Other properties which may be included. */ [prop: string]: any; } /** * Empty room event content. * @category Matrix event contents */ export interface RoomEventContent { // This is empty so people can avoid using RoomEvent } /** * A Matrix room event. * @category Matrix events */ export class RoomEvent extends MatrixEvent { constructor(protected event: any) { super(event); } /** * The event ID of this event. */ public get eventId(): string { return this.event['event_id']; } /** * The timestamp in milliseconds this event was sent. */ public get timestamp(): number { return this.event['origin_server_ts']; } /** * The unsigned content for this event. May have no properties. */ public get unsigned(): TypicalUnsigned { return this.event['unsigned'] || {}; } } /** * A room state event. * @category Matrix events */ export class StateEvent extends RoomEvent { constructor(event: any) { super(event); } /** * The state key for this event. May be an empty string. */ public get stateKey(): string { return this.event['state_key']; } /** * The previous content for this state event. Will be an empty * object if there is no previous content. */ public get previousContent(): T { return this.unsigned['prev_content'] || this.event['prev_content'] || {}; // v2, v1, fallback } } ================================================ FILE: src/models/events/RoomNameEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.name events * @category Matrix event contents * @see RoomNameEvent */ export interface RoomNameEventContent { name: string; } /** * Represents an m.room.name state event * @category Matrix events */ export class RoomNameEvent extends StateEvent { constructor(event: any) { super(event); } /** * The name of the room. */ public get name(): string { return this.content.name; } } ================================================ FILE: src/models/events/RoomTopicEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.room.topic events * @category Matrix event contents * @see RoomTopicEvent */ export interface RoomTopicEventContent { topic: string; } /** * Represents an m.room.topic state event * @category Matrix events */ export class RoomTopicEvent extends StateEvent { constructor(event: any) { super(event); } /** * The topic of the room. */ public get topic(): string { return this.content.topic; } } ================================================ FILE: src/models/events/SpaceChildEvent.ts ================================================ import { StateEvent } from "./RoomEvent"; /** * The content definition for m.space.child events * @category Matrix event contents * @see SpaceChildEvent */ export interface SpaceChildEventContent { /** * The servers the client should provide to the server when trying to join the room. * When not provided or empty, the child is considered not part of the space. */ via: string[]; /** * A short string to differentiate the rendering order of entities. * @see validateSpaceOrderString */ order?: string; /** * Whether or not the entity is intended to be a suggested entity. */ suggested?: boolean; } /** * Represents an m.space.child state event * @category Matrix events */ export class SpaceChildEvent extends StateEvent { constructor(event: any) { super(event); } /** * The room ID of the space or room this child represents. */ public get entityRoomId(): string { return this.stateKey; } /** * Whether or not this child is "active" or valid. Inactive children are * not considered part of a space. */ public get isActiveChild(): boolean { return !!this.viaServers?.length; } /** * The servers the client should provide to the homeserver when trying to * join the entity (room). May be empty or falsey to denote the child is * inactive. */ public get viaServers(): string[] { return this.content.via; } /** * An optional short string to differentiate the rendering order of entities. * @see validateSpaceOrderString */ public get order(): string { return this.content.order; } /** * Whether or not the child is a suggested entity for users to join. */ public get suggested(): boolean { return this.content.suggested ?? false; } } ================================================ FILE: src/models/events/_MissingEvents.md ================================================ ## Missing event types These are the event types that need definitions. ### r0.6.0 * `m.call.invite` * `m.call.candidates` * `m.call.answer` * `m.call.hangup` * `m.typing` * `m.receipt` * `m.fully_read` * `m.key.verification.request` * `m.key.verification.start` * `m.key.verification.cancel` * `m.key.verification.start` (SAS) * `m.key.verification.accept` * `m.key.verification.key` * `m.key.verification.mac` * `m.room_key` * `m.room_key_request` * `m.forwarded_room_key` * `m.dummy` * `m.room.history_visibility` * `m.push_rules` * `m.room.third_party_invite` * `m.room.guest_access` * `m.tag` * `m.direct` * `m.ignored_user_list` * `m.sticker` * `m.room.server_acl` * `m.room.tombstone` * `m.room.message` of type `m.server_notice` ================================================ FILE: src/models/events/converter.ts ================================================ import { MembershipEvent } from "./MembershipEvent"; import { RoomEvent, StateEvent } from "./RoomEvent"; import { AudioMessageEventContent, ImageMessageEventContent, LocationMessageEventContent, MessageEvent, MessageEventContent, TextualMessageEventContent, VideoMessageEventContent, } from "./MessageEvent"; /** * Wraps a room event into a more suitable container. * @param {any} event The event object to wrap. * @returns {RoomEvent} An instance of the most suitable container for the event. * @category Matrix events */ export function wrapRoomEvent(event: any): RoomEvent { if (!event) return null; if (typeof (event['state_key']) === 'string') { if (event['type'] === 'm.room.member') { return new MembershipEvent(event); } else { return new StateEvent(event); } } else if (event['type'] === 'm.room.message') { const content = event['content']; const msgtype = content ? content['msgtype'] : null; if (msgtype === "m.text" || msgtype === "m.notice" || msgtype === "m.emote") { return new MessageEvent(event); } else if (msgtype === "m.audio") { return new MessageEvent(event); } else if (msgtype === "m.video") { return new MessageEvent(event); } else if (msgtype === "m.image") { return new MessageEvent(event); } else if (msgtype === "m.location") { return new MessageEvent(event); } else { return new MessageEvent(event); } } else { return new RoomEvent(event); } } ================================================ FILE: src/models/unstable/MediaInfo.ts ================================================ /** * This interface implements the schema defined in [MSC2380](https://github.com/matrix-org/matrix-doc/pull/2380). * @category Unstable APIs */ export interface MSC2380MediaInfo { content_type: string; width?: number; height?: number; size: number; thumbnails?: { width: number; height: number; ready: boolean; }[]; duration?: number; } ================================================ FILE: src/preprocessors/IPreprocessor.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { EventKind } from ".."; /** * Represents a preprocessor. * @category Preprocessors */ export interface IPreprocessor { /** * Gets the types of events this preprocessor supports. */ getSupportedEventTypes(): string[]; /** * Processes an event, modifying it in-place if needed. * @param {any} event The event that should be processed. * @param {MatrixClient} client The Matrix client that is providing the event. * @param {EventKind|null|undefined} kind Optional kind identifier for an event. When not * supplied, the event is assumed to be a RoomEvent. * @returns {Promise} Resolved when the event is has been modified. The resolved * value is ignored. */ processEvent(event: any, client: MatrixClient, kind?: EventKind): Promise; } ================================================ FILE: src/preprocessors/RichRepliesPreprocessor.ts ================================================ import { MatrixClient } from "../MatrixClient"; import { IPreprocessor } from "./IPreprocessor"; import { EventKind, extractRequestError, LogService } from ".."; /** * Metadata for a rich reply. Usually stored under the "mx_richreply" * field of an event (at the top level). * @category Preprocessors * @see RichRepliesPreprocessor */ export interface IRichReplyMetadata { /** * If true, the preprocessor found some inconsistencies in the reply * information that does not match the specification. For example, * this may indicate that a reply was sent without an HTML component. */ wasLenient: boolean; /** * The event ID the event references. May be an empty string if * wasLenient is true. */ parentEventId: string; /** * The fallback plain text the preprocessor found. May be an empty * string if wasLenient is true. The prefix characters to indicate * this is a fallback will have already been removed. */ fallbackPlainBody: string; /** * The fallback HTML the processor found. May be an empty string if * wasLenient is true. The fallback structure will have already been * removed, leaving just the original assumed HTML. */ fallbackHtmlBody: string; /** * The user ID that sent the parent event, as determined by the fallback * text. This should not be relied upon for anything serious, and instead * the preprocessor should be configured to fetch the real event to * populate the realEvent property. May be an empty string if wasLenient * is true. */ fallbackSender: string; /** * If the preprocessor is configured to fetch event content, this field * will contain the event as reported by the homeserver. May be null if * wasLenient is true. */ realEvent: any; } /** * Processes rich replies found in events. This automatically strips * the fallback representation from events, providing the information * as a top level "mx_richreply" key. The "mx_richreply" property may * be cast to the type IRichReplyMetadata. * @category Preprocessors */ export class RichRepliesPreprocessor implements IPreprocessor { /** * Creates a new rich replies preprocessor. * @param fetchRealEventContents If enabled, this preprocessor will * attempt to get the real event contents and append them to the event * information. */ public constructor(private fetchRealEventContents = false) { } public getSupportedEventTypes(): string[] { return ["m.room.message"]; } public async processEvent(event: any, client: MatrixClient, kind?: EventKind): Promise { if (kind && kind !== EventKind.RoomEvent) return; if (!event["content"]) return; if (!event["content"]["m.relates_to"]) return; if (!event["content"]["m.relates_to"]["m.in_reply_to"]) return; const parentEventId = event["content"]["m.relates_to"]["m.in_reply_to"]["event_id"]; if (!parentEventId) return; let fallbackHtml = ""; let fallbackText = ""; let fallbackSender = ""; let realHtml = event["content"]["formatted_body"]; let realText = event["content"]["body"]; let lenient = false; if (event["content"]["format"] !== "org.matrix.custom.html" || !event["content"]["formatted_body"]) { lenient = true; // Not safe to parse: probably not HTML } else { const formattedBody = event["content"]["formatted_body"]; if (!formattedBody.startsWith("") || formattedBody.indexOf("") === -1) { lenient = true; // Doesn't look like a reply } else { const parts = formattedBody.split(""); const fbHtml = parts[0]; realHtml = parts[1]; const results = fbHtml.match(/(.*)<\/blockquote>\s*$/i); if (!results) { lenient = true; } else { fallbackHtml = results[1]; } } } let processedFallback = false; const body = event["content"]["body"] || ""; for (const line of body.split("\n")) { if (line.startsWith("> ") && !processedFallback) { fallbackText += line.substring(2) + "\n"; } else if (!processedFallback) { realText = ""; processedFallback = true; } else { realText += line + "\n"; } } const firstFallbackLine = fallbackText.split("\n")[0]; const matches = firstFallbackLine.match(/<(@.*:.*)>/); if (!matches) { lenient = true; } else { fallbackSender = matches[1]; } const metadata: IRichReplyMetadata = { wasLenient: lenient, fallbackHtmlBody: fallbackHtml ? fallbackHtml.trim() : "", fallbackPlainBody: fallbackText ? fallbackText.trim() : "", fallbackSender: fallbackSender ? fallbackSender.trim() : "", parentEventId: parentEventId ? parentEventId.trim() : "", realEvent: null, }; if (this.fetchRealEventContents) { try { metadata.realEvent = await client.getEvent(event["room_id"], parentEventId); } catch (e) { LogService.error("RichRepliesPreprocessor", "Failed to fetch real event:"); LogService.error("RichRepliesPreprocessor", extractRequestError(e)); metadata.wasLenient = true; // failed to fetch event } } event["mx_richreply"] = metadata; event["content"]["body"] = realText.trim(); if (realHtml) event["content"]["formatted_body"] = realHtml.trim(); return event; } } ================================================ FILE: src/request.ts ================================================ import * as origRequestFn from "request"; let requestFn = origRequestFn; /** * Sets the function to use for performing HTTP requests. Must be compatible with `request`. * @param fn The new request function. * @category Unit testing */ export function setRequestFn(fn) { requestFn = fn; } /** * Gets the `request`-compatible function for performing HTTP requests. * @returns The request function. * @category Unit testing */ export function getRequestFn(): typeof origRequestFn { return requestFn; } ================================================ FILE: src/simple-validation.ts ================================================ /** * Validate the 'order' parameter of a child space entry. It must be a * string between the range of \x20 - \x7F and contain no more than 50 * characters. * @param {string} order The 'order' parameter of a m.space.child * @throws {Error} If the string is not valid * @returns {boolean} True if the string is valid * @category Utilities */ export function validateSpaceOrderString(order: string): true { if (typeof (order) !== 'string') { // Just in case, even though TS should catch this. throw Error('order is not a string'); } if (order.length === 0) { throw Error('order cannot be empty'); } if (order.length > 50) { throw Error('order is more than 50 characters and is disallowed'); } if (!order.match(/^[\x20-\x7E]+$/)) { // String must be between this range throw Error('order contained characters outside the range of the spec.'); } return true; } ================================================ FILE: src/storage/IAppserviceStorageProvider.ts ================================================ import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { IStorageProvider } from "./IStorageProvider"; /** * A storage provider definition for appservices to use. * @category Storage providers */ export interface IAppserviceStorageProvider { /** * Tracks a user ID as "registered". * @returns {Promise|void} Resolves when complete. */ addRegisteredUser(userId: string): Promise | void; /** * Determines if a user ID is registered or not. * @returns {boolean|Promise} True if registered. This may be a promise. */ isUserRegistered(userId: string): boolean | Promise; /** * Flags a transaction as completed. * @param {string} transactionId The transaction ID. * @returns {Promise|void} Resolves when complete. */ setTransactionCompleted(transactionId: string): Promise | void; /** * Determines if a transaction has been flagged as completed. * @param {string} transactionId The transaction ID to check. * @returns {boolean} True if the transaction has been completed. This may be a promise. */ isTransactionCompleted(transactionId: string): boolean | Promise; /** * Gets a storage provider to use for the given user ID. * @param {string} userId The user ID. * @returns {ICryptoStorageProvider} The storage provider. */ storageForUser?(userId: string): IStorageProvider; } /** * A storage provider capable of only providing crypto-related storage to appservices. * @category Storage providers */ export interface IAppserviceCryptoStorageProvider { /** * Gets a storage provider to use for the given user ID. * @param {string} userId The user ID. * @returns {ICryptoStorageProvider} The storage provider. */ storageForUser(userId: string): ICryptoStorageProvider; } ================================================ FILE: src/storage/ICryptoStorageProvider.ts ================================================ import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation"; /** * A storage provider capable of only providing crypto-related storage. * @category Storage providers */ export interface ICryptoStorageProvider { /** * Sets the client's device ID. * @param {string} deviceId The device ID. * @returns {Promise} Resolves when complete. */ setDeviceId(deviceId: string): Promise; /** * Gets the client's device ID, if known. * @returns {Promise} Resolves to the device ID, or falsy if not known. */ getDeviceId(): Promise; /** * Stores a room's configuration. * @param {string} roomId The room ID to store the configuration for. * @param {ICryptoRoomInformation} config The room's encryption config. May be empty. * @returns {Promise} Resolves when complete. */ storeRoom(roomId: string, config: ICryptoRoomInformation): Promise; /** * Gets a room's configuration. If the room is unknown, a falsy value is returned. * @param {string} roomId The room ID to get the configuration for. * @returns {Promise} Resolves to the room's configuration, or * to falsy if the room is unknown. */ getRoom(roomId: string): Promise; } ================================================ FILE: src/storage/IStorageProvider.ts ================================================ import { IFilterInfo } from "../IFilter"; /** * Represents a storage provider for the matrix client * @category Storage providers */ export interface IStorageProvider { /** * Sets the sync token, saving it for later retrieval * @param {string} token The token to save * @returns {Promise|void} Resolves when complete. */ setSyncToken(token: string | null): Promise | void; /** * Gets the last saved sync token, or null if none has been persisted. * @returns {String|Promise} The last sync token, or null. This can * also be a promise for the value. */ getSyncToken(): string | Promise | null; /** * Sets the filter to be used by future clients * @param {IFilterInfo} filter The filter to store * @returns {Promise|void} Resolves when complete. */ setFilter(filter: IFilterInfo): Promise | void; /** * Gets the last preferred filter for this client * @returns {IFilterInfo|Promise} The last saved filter, or null. * This can also be a promise for the filter. */ getFilter(): IFilterInfo | Promise; /** * Store a simple string value into the provided key. * @param {string} key The key to store the value under. * @param {string} value The value to store. * @returns {Promise | void} Resolves when complete. */ storeValue(key: string, value: string): Promise | void; /** * Reads a previously stored value under the given key. If the * key does not exist, null or undefined is returned. * @param {string} key The key to read. * @returns {string|Promise|null|undefined} The * value, or null/undefined if not found. This may also return a promise * of those same values. */ readValue(key: string): string | Promise | null | undefined; } ================================================ FILE: src/storage/MemoryStorageProvider.ts ================================================ import { IStorageProvider } from "./IStorageProvider"; import { IFilterInfo } from "../IFilter"; import { IAppserviceStorageProvider } from "./IAppserviceStorageProvider"; /** * A storage provider that persists no information by keeping it all in memory. * @category Storage providers */ export class MemoryStorageProvider implements IStorageProvider, IAppserviceStorageProvider { private namespaced = new Map(); private syncToken: string; private filter: IFilterInfo; private appserviceUsers: { [userId: string]: { registered: boolean } } = {}; private appserviceTransactions: { [txnId: string]: boolean } = {}; private kvStore: { [key: string]: string } = {}; setSyncToken(token: string | null): void { this.syncToken = token; } getSyncToken(): string | null { return this.syncToken; } setFilter(filter: IFilterInfo): void { this.filter = filter; } getFilter(): IFilterInfo { return this.filter; } addRegisteredUser(userId: string) { this.appserviceUsers[userId] = { registered: true, }; } isUserRegistered(userId: string): boolean { return this.appserviceUsers[userId] && this.appserviceUsers[userId].registered; } isTransactionCompleted(transactionId: string): boolean { return !!this.appserviceTransactions[transactionId]; } setTransactionCompleted(transactionId: string) { this.appserviceTransactions[transactionId] = true; } readValue(key: string): string | null | undefined { return this.kvStore[key]; } storeValue(key: string, value: string): void { this.kvStore[key] = value; } storageForUser(userId: string): IStorageProvider { if (!this.namespaced.has(userId)) { this.namespaced.set(userId, new MemoryStorageProvider()); } return this.namespaced.get(userId); } } ================================================ FILE: src/storage/RustSdkCryptoStorageProvider.ts ================================================ import * as lowdb from "lowdb"; import * as FileSync from "lowdb/adapters/FileSync"; import * as mkdirp from "mkdirp"; import * as path from "path"; import * as sha512 from "hash.js/lib/hash/sha/512"; import * as sha256 from "hash.js/lib/hash/sha/256"; import { StoreType as RustSdkCryptoStoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { ICryptoStorageProvider } from "./ICryptoStorageProvider"; import { IAppserviceCryptoStorageProvider } from "./IAppserviceStorageProvider"; import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation"; export { RustSdkCryptoStoreType }; /** * A crypto storage provider for the file-based rust-sdk store. * @category Storage providers */ export class RustSdkCryptoStorageProvider implements ICryptoStorageProvider { private db: lowdb.LowdbSync; /** * Creates a new rust-sdk storage provider. * @param storagePath The *directory* to persist database details to. * @param storageType The storage type to use. Must be supported by the rust-sdk. */ public constructor( public readonly storagePath: string, public readonly storageType: RustSdkCryptoStoreType, ) { this.storagePath = path.resolve(this.storagePath); mkdirp.sync(storagePath); const adapter = new FileSync(path.join(storagePath, "bot-sdk.json")); this.db = lowdb(adapter); this.db.defaults({ deviceId: null, rooms: {}, }); } public async getDeviceId(): Promise { return this.db.get('deviceId').value(); } public async setDeviceId(deviceId: string): Promise { this.db.set('deviceId', deviceId).write(); } public async getRoom(roomId: string): Promise { const key = sha512().update(roomId).digest('hex'); return this.db.get(`rooms.${key}`).value(); } public async storeRoom(roomId: string, config: ICryptoRoomInformation): Promise { const key = sha512().update(roomId).digest('hex'); this.db.set(`rooms.${key}`, config).write(); } } /** * An appservice crypto storage provider for the file-based rust-sdk store. * @category Storage providers */ export class RustSdkAppserviceCryptoStorageProvider extends RustSdkCryptoStorageProvider implements IAppserviceCryptoStorageProvider { /** * Creates a new rust-sdk storage provider. * @param baseStoragePath The *directory* to persist database details to. * @param storageType The storage type to use. Must be supported by the rust-sdk. */ public constructor(private baseStoragePath: string, storageType: RustSdkCryptoStoreType) { super(path.join(baseStoragePath, "_default"), storageType); } public storageForUser(userId: string): ICryptoStorageProvider { // sha256 because sha512 is a bit big for some operating systems const key = sha256().update(userId).digest('hex'); return new RustSdkCryptoStorageProvider(path.join(this.baseStoragePath, key), this.storageType); } } ================================================ FILE: src/storage/SimpleFsStorageProvider.ts ================================================ import * as lowdb from "lowdb"; import * as FileSync from "lowdb/adapters/FileSync"; import * as sha512 from "hash.js/lib/hash/sha/512"; import * as mkdirp from "mkdirp"; import * as path from "path"; import { IAppserviceStorageProvider } from "./IAppserviceStorageProvider"; import { IFilterInfo } from "../IFilter"; import { IStorageProvider } from "./IStorageProvider"; /** * A storage provider that uses the disk to store information. * @category Storage providers */ export class SimpleFsStorageProvider implements IStorageProvider, IAppserviceStorageProvider { private db: any; private completedTransactions = []; /** * Creates a new simple file system storage provider. * @param {string} filename The file name (typically 'storage.json') to store data within. * @param {boolean} trackTransactionsInMemory True (default) to track all received appservice transactions rather than on disk. * @param {int} maxInMemoryTransactions The maximum number of transactions to hold in memory before rotating the oldest out. Defaults to 20. */ constructor(filename: string, private trackTransactionsInMemory = true, private maxInMemoryTransactions = 20) { mkdirp.sync(path.dirname(filename)); const adapter = new FileSync(filename); this.db = lowdb(adapter); this.db.defaults({ syncToken: null, filter: null, appserviceUsers: {}, // userIdHash => { data } appserviceTransactions: {}, // txnIdHash => { data } kvStore: {}, // key => value (str) }).write(); } setSyncToken(token: string | null): void { this.db.set('syncToken', token).write(); } getSyncToken(): string | null { return this.db.get('syncToken').value(); } setFilter(filter: IFilterInfo): void { this.db.set('filter', filter).write(); } getFilter(): IFilterInfo { return this.db.get('filter').value(); } addRegisteredUser(userId: string) { const key = sha512().update(userId).digest('hex'); this.db .set(`appserviceUsers.${key}.userId`, userId) .set(`appserviceUsers.${key}.registered`, true) .write(); } isUserRegistered(userId: string): boolean { const key = sha512().update(userId).digest('hex'); return this.db.get(`appserviceUsers.${key}.registered`).value(); } isTransactionCompleted(transactionId: string): boolean { if (this.trackTransactionsInMemory) { return this.completedTransactions.indexOf(transactionId) !== -1; } const key = sha512().update(transactionId).digest('hex'); return this.db.get(`appserviceTransactions.${key}.completed`).value(); } setTransactionCompleted(transactionId: string) { if (this.trackTransactionsInMemory) { if (this.completedTransactions.indexOf(transactionId) === -1) { this.completedTransactions.push(transactionId); } if (this.completedTransactions.length > this.maxInMemoryTransactions) { this.completedTransactions = this.completedTransactions.reverse().slice(0, this.maxInMemoryTransactions).reverse(); } return; } const key = sha512().update(transactionId).digest('hex'); this.db .set(`appserviceTransactions.${key}.txnId`, transactionId) .set(`appserviceTransactions.${key}.completed`, true) .write(); } readValue(key: string): string | null | undefined { return this.db.get("kvStore").value()[key]; } storeValue(key: string, value: string): void { const kvStore = this.db.get("kvStore").value(); kvStore[key] = value; this.db.set("kvStore", kvStore).write(); } storageForUser(userId: string): IStorageProvider { return new NamespacedFsProvider(userId, this); } } /** * A namespaced storage provider that uses the disk to store information. * @category Storage providers */ class NamespacedFsProvider implements IStorageProvider { constructor(private prefix: string, private parent: SimpleFsStorageProvider) { } setFilter(filter: IFilterInfo): Promise | void { return this.parent.storeValue(`${this.prefix}_int_filter`, JSON.stringify(filter)); } getFilter(): IFilterInfo | Promise { return Promise.resolve(this.parent.readValue(`${this.prefix}_int_filter`)).then(r => r ? JSON.parse(r) : r); } setSyncToken(token: string | null): Promise | void { return this.parent.storeValue(`${this.prefix}_int_syncToken`, token); } getSyncToken(): string | Promise | null { return Promise.resolve(this.parent.readValue(`${this.prefix}_int_syncToken`)).then(r => r ?? null); } readValue(key: string): string | Promise | null | undefined { return this.parent.readValue(`${this.prefix}_kv_${key}`); } storeValue(key: string, value: string): Promise | void { return this.parent.storeValue(`${this.prefix}_kv_${key}`, value); } } ================================================ FILE: src/storage/SimplePostgresStorageProvider.ts ================================================ import * as postgres from "postgres"; import { IStorageProvider } from "./IStorageProvider"; import { IAppserviceStorageProvider } from "./IAppserviceStorageProvider"; import { IFilterInfo } from "../IFilter"; /** * A barebones postgresql storage provider. It is not efficient, but it does work. * @category Storage providers */ export class SimplePostgresStorageProvider implements IStorageProvider, IAppserviceStorageProvider { private readonly db: postgres.Sql; private readonly waitPromise: Promise; private completedTransactions = []; /** * Creates a new simple postgresql storage provider. * @param connectionString The `postgres://` connection string to use. * @param trackTransactionsInMemory True (default) to track all received appservice transactions rather than on disk. * @param maxInMemoryTransactions The maximum number of transactions to hold in memory before rotating the oldest out. Defaults to 20. */ constructor(connectionString: string, private trackTransactionsInMemory = true, private maxInMemoryTransactions = 20) { this.db = postgres(connectionString); this.waitPromise = Promise.all([ this.db` CREATE TABLE IF NOT EXISTS bot_metadata (key TEXT NOT NULL PRIMARY KEY, value TEXT); `, this.db` CREATE TABLE IF NOT EXISTS bot_kv (key TEXT NOT NULL PRIMARY KEY, value TEXT); `, this.db` CREATE TABLE IF NOT EXISTS appservice_users (user_id TEXT NOT NULL PRIMARY KEY, registered BOOLEAN NOT NULL); `, this.db` CREATE TABLE IF NOT EXISTS appservice_transactions (txn_id TEXT NOT NULL PRIMARY KEY, completed BOOLEAN NOT NULL); `, ]).then(); } public async setSyncToken(token: string | null): Promise { await this.waitPromise; return this.db` INSERT INTO bot_metadata (key, value) VALUES ('syncToken', ${token}) ON CONFLICT (key) DO UPDATE SET value = ${token}; `; } public async getSyncToken(): Promise { await this.waitPromise; return (await this.db` SELECT value FROM bot_metadata WHERE key = 'syncToken'; `)[0]?.value; } public async setFilter(filter: IFilterInfo): Promise { await this.waitPromise; const filterStr = filter ? JSON.stringify(filter) : null; return this.db` INSERT INTO bot_metadata (key, value) VALUES ('filter', ${filterStr}) ON CONFLICT (key) DO UPDATE SET value = ${filterStr}; `; } public async getFilter(): Promise { await this.waitPromise; const value = (await this.db` SELECT value FROM bot_metadata WHERE key = 'filter'; `)[0]?.value; return typeof value === "string" ? JSON.parse(value) : value; } public async addRegisteredUser(userId: string): Promise { await this.waitPromise; return this.db` INSERT INTO appservice_users (user_id, registered) VALUES (${userId}, TRUE) ON CONFLICT (user_id) DO UPDATE SET registered = TRUE; `; } public async isUserRegistered(userId: string): Promise { await this.waitPromise; return !!(await this.db` SELECT registered FROM appservice_users WHERE user_id = ${userId}; `)[0]?.registered; } public async setTransactionCompleted(transactionId: string): Promise { await this.waitPromise; if (this.trackTransactionsInMemory) { if (this.completedTransactions.indexOf(transactionId) === -1) { this.completedTransactions.push(transactionId); } if (this.completedTransactions.length > this.maxInMemoryTransactions) { this.completedTransactions = this.completedTransactions.reverse().slice(0, this.maxInMemoryTransactions).reverse(); } return; } return this.db` INSERT INTO appservice_transactions (txn_id, completed) VALUES (${transactionId}, TRUE) ON CONFLICT (txn_id) DO UPDATE SET completed = TRUE; `; } public async isTransactionCompleted(transactionId: string): Promise { await this.waitPromise; if (this.trackTransactionsInMemory) { return this.completedTransactions.includes(transactionId); } return (await this.db` SELECT completed FROM appservice_transactions WHERE txn_id = ${transactionId}; `)[0]?.completed; } public async readValue(key: string): Promise { await this.waitPromise; return (await this.db` SELECT value FROM bot_kv WHERE key = ${key}; `)[0]?.value; } public async storeValue(key: string, value: string): Promise { await this.waitPromise; return this.db` INSERT INTO bot_kv (key, value) VALUES (${key}, ${value}) ON CONFLICT (key) DO UPDATE SET value = ${value}; `.then(); } public storageForUser(userId: string): IStorageProvider { return new NamespacedPostgresProvider(userId, this); } } /** * A namespaced storage provider that uses postgres to store information. * @category Storage providers */ class NamespacedPostgresProvider implements IStorageProvider { constructor(private prefix: string, private parent: SimplePostgresStorageProvider) { } public setFilter(filter: IFilterInfo): Promise | void { return this.parent.storeValue(`${this.prefix}_internal_filter`, JSON.stringify(filter)); } public async getFilter(): Promise { return this.parent.readValue(`${this.prefix}_internal_filter`).then(r => r ? JSON.parse(r) : r); } public setSyncToken(token: string | null): Promise | void { return this.parent.storeValue(`${this.prefix}_internal_syncToken`, token ?? ""); } public async getSyncToken(): Promise { return this.parent.readValue(`${this.prefix}_internal_syncToken`).then(r => r === "" ? null : r); } public storeValue(key: string, value: string): Promise | void { return this.parent.storeValue(`${this.prefix}_internal_kv_${key}`, value); } public readValue(key: string): string | Promise | null | undefined { return this.parent.readValue(`${this.prefix}_internal_kv_${key}`); } } ================================================ FILE: src/strategies/AppserviceJoinRoomStrategy.ts ================================================ import { IJoinRoomStrategy } from "./JoinRoomStrategy"; import { Appservice } from "../appservice/Appservice"; /** * A join strategy for application services that proxies joins to an underlying join * strategy while also trying to use the appservice's bot user to invite the underlying * user if needed. * @category Join strategies */ export class AppserviceJoinRoomStrategy implements IJoinRoomStrategy { constructor(private underlyingStrategy: IJoinRoomStrategy, private appservice: Appservice) { } public async joinRoom(roomIdOrAlias: string, userId: string, apiCall: (targetRoomIdOrAlias: string) => Promise): Promise { try { // First just try joining via the apiCall return await apiCall(roomIdOrAlias); } catch (err) { // If the user being joined is *not* the bridge bot, try and get the bridge bot to // join them to the room. if (userId !== this.appservice.botUserId) { const client = this.appservice.botIntent.underlyingClient; const roomId = await client.resolveRoom(roomIdOrAlias); try { // First start with having the bridge bot invite the user to the room await client.inviteUser(userId, roomId); } catch (inviteErr) { // The invite failed - use the underlying join strategy to join the room, just in case. // If there's no join strategy, we want to fall through to an error. if (this.underlyingStrategy) return this.underlyingStrategy.joinRoom(roomId, userId, apiCall); throw inviteErr; } // The invite succeeded - use the underlying join strategy to join the room or just call use // the apiCall if no strategy exists. We are expecting success. if (this.underlyingStrategy) return this.underlyingStrategy.joinRoom(roomId, userId, apiCall); else return apiCall(roomId); } else if (this.underlyingStrategy) { // If the user being joined *is* the bridge bot, try and use the join strategy to join. return this.underlyingStrategy.joinRoom(roomIdOrAlias, userId, apiCall); } // Finally, if all else fails, throw. throw err; } } } ================================================ FILE: src/strategies/JoinRoomStrategy.ts ================================================ import { extractRequestError, LogService } from ".."; export interface IJoinRoomStrategy { joinRoom(roomIdOrAlias: string, userId: string, apiCall: (targetRoomIdOrAlias: string) => Promise): Promise; } /** * A join strategy that keeps trying to join the room on a set interval. * @category Join strategies */ export class SimpleRetryJoinStrategy implements IJoinRoomStrategy { // Note: The schedule must not have duplicate values to avoid problems in positioning. private schedule = [ 0, // Right away 1000, // 1 second 30 * 1000, // 30 seconds 5 * 60 * 1000, // 5 minutes 15 * 60 * 1000, // 15 minutes ]; public joinRoom(roomIdOrAlias: string, userId: string, apiCall: (targetRoomIdOrAlias: string) => Promise): Promise { let currentSchedule = this.schedule[0]; const doJoin = () => waitPromise(currentSchedule).then(() => apiCall(roomIdOrAlias)); const errorHandler = err => { LogService.error("SimpleRetryJoinStrategy", extractRequestError(err)); const idx = this.schedule.indexOf(currentSchedule); if (idx === this.schedule.length - 1) { LogService.warn("SimpleRetryJoinStrategy", "Failed to join room " + roomIdOrAlias); return Promise.reject(err); } else { currentSchedule = this.schedule[idx + 1]; return doJoin().catch(errorHandler); } }; return doJoin().catch(errorHandler); } } function waitPromise(interval: number): Promise { return new Promise((resolve, reject) => { setTimeout(resolve, interval); }); } ================================================ FILE: test/AdminApisTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import { AdminApis, IStorageProvider, MatrixClient, WhoisInfo } from "../src"; import { createTestClient } from "./TestUtils"; export function createTestAdminClient(storage: IStorageProvider = null): { client: AdminApis, mxClient: MatrixClient, http: HttpBackend, hsUrl: string, accessToken: string } { const result = createTestClient(storage); const mxClient = result.client; const client = new AdminApis(mxClient); delete result.client; return { ...result, client, mxClient }; } describe('AdminApis', () => { describe('whoisUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestAdminClient(); const userId = "@someone:example.org"; const response: WhoisInfo = { user_id: userId, devices: { foobar: { sessions: [{ connections: [{ ip: "127.0.0.1", last_seen: 1000, user_agent: "FakeDevice/1.0.0", }], }], }, }, }; http.when("GET", "/_matrix/client/v3/admin/whois").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/admin/whois/${encodeURIComponent(userId)}`); return response; }); const result = client.whoisUser(userId); await http.flushAllExpected(); expect(await result).toMatchObject(response); }); }); }); ================================================ FILE: test/DMsTest.ts ================================================ import * as simple from "simple-mock"; import { EncryptionAlgorithm } from "../src"; import { createTestClient, testCryptoStores, TEST_DEVICE_ID } from "./TestUtils"; describe('DMs', () => { it('should update the cache when an sync requests happen', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId1 = "!dm:example.org"; const dmUserId1 = "@one:example.org"; const dmRoomId2 = "!dm2:example.org"; const dmUserId2 = "@two:example.org"; const accountDataDms = { [dmUserId2]: [dmRoomId2], }; // noinspection TypeScriptValidateJSTypes http.when("GET", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, accountDataDms); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ ...accountDataDms, [dmUserId1]: [dmRoomId1], }); return {}; }); const flush = http.flushAllExpected(); const accountHandleProm = new Promise(resolve => { const orig = (dms).updateFromAccountData.bind(dms); (dms).updateFromAccountData = simple.stub().callFn(async (ev) => { await orig(ev); resolve(); }); }); const inviteHandleProm = new Promise(resolve => { const orig = (dms).handleInvite.bind(dms); (dms).handleInvite = simple.stub().callFn(async (rid, ev) => { await orig(rid, ev); resolve(); }); }); expect(dms.isDm(dmRoomId1)).toBe(false); expect(dms.isDm(dmRoomId2)).toBe(false); client.emit("account_data", { type: "m.direct", content: { "@unused:example.org": [ "!WRONG:example.org", ], }, }); await accountHandleProm; expect(dms.isDm(dmRoomId1)).toBe(false); expect(dms.isDm(dmRoomId2)).toBe(true); client.emit("room.invite", dmRoomId1, { type: "m.room.member", sender: dmUserId1, state_key: selfUserId, content: { membership: "invite", is_direct: true, }, }); await inviteHandleProm; expect(dms.isDm(dmRoomId1)).toBe(true); expect(dms.isDm(dmRoomId2)).toBe(true); await flush; }); it('should update from account data when requested', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@one:example.org"; const accountDataDms = { [dmUserId]: [dmRoomId], }; // noinspection TypeScriptValidateJSTypes http.when("GET", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, accountDataDms); expect(dms.isDm(dmRoomId)).toBe(false); await Promise.all([dms.update(), http.flushAllExpected()]); expect(dms.isDm(dmRoomId)).toBe(true); }); it('should not fail to update when the account data is missing/fails', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId = "!dm:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(404); expect(dms.isDm(dmRoomId)).toBe(false); await Promise.all([dms.update(), http.flushAllExpected()]); expect(dms.isDm(dmRoomId)).toBe(false); }); it('should create a DM if one does not exist', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", `/createRoom`).respond(200, (path, body) => { expect(body).toEqual({ invite: [dmUserId], is_direct: true, preset: "trusted_private_chat", initial_state: [], }); return { room_id: dmRoomId }; }); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ [dmUserId]: [dmRoomId], }); return {}; }); const flush = http.flushAllExpected(); expect(dms.isDm(dmRoomId)).toBe(false); const roomId = await dms.getOrCreateDm(dmUserId); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); await flush; }); it('should call the optional create room function when provided', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; const fn = simple.stub().callFn(async (uid) => { expect(uid).toEqual(dmUserId); return dmRoomId; }); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ [dmUserId]: [dmRoomId], }); return {}; }); const flush = http.flushAllExpected(); expect(dms.isDm(dmRoomId)).toBe(false); const roomId = await dms.getOrCreateDm(dmUserId, fn); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); expect(fn.callCount).toBe(1); await flush; }); it('should try to patch up DMs when a DM is potentially known', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; const deadRoomId = "!unused:example.org"; const accountDataDms = { [dmUserId]: [deadRoomId, dmRoomId], }; // noinspection TypeScriptValidateJSTypes http.when("GET", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, accountDataDms); // noinspection TypeScriptValidateJSTypes http.when("GET", `rooms/${encodeURIComponent(deadRoomId)}/members`).respond(200, (path, body) => { return { chunk: [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: selfUserId, content: { membership: "join", }, }, { type: "m.room.member", state_key: dmUserId, content: { membership: "leave", }, }, ], }; }); // noinspection TypeScriptValidateJSTypes http.when("GET", `rooms/${encodeURIComponent(dmRoomId)}/members`).respond(200, (path, body) => { return { chunk: [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: selfUserId, content: { membership: "join", }, }, { type: "m.room.member", state_key: dmUserId, content: { membership: "join", }, }, ], }; }); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ [dmUserId]: [dmRoomId], }); return {}; }); const flush = http.flushAllExpected(); await dms.update(); expect(dms.isDm(dmRoomId)).toBe(true); expect(dms.isDm(deadRoomId)).toBe(true); const roomId = await dms.getOrCreateDm(dmUserId); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); expect(dms.isDm(deadRoomId)).toBe(false); await flush; }); it('should use the cache if a DM already exists', async () => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId); const dms = client.dms; // Stop calls to `/members` (dms).fixDms = () => Promise.resolve(); const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; const accountDataDms = { [dmUserId]: [dmRoomId], }; // noinspection TypeScriptValidateJSTypes http.when("GET", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, accountDataDms); const flush = http.flushAllExpected(); await dms.update(); expect(dms.isDm(dmRoomId)).toBe(true); const roomId = await dms.getOrCreateDm(dmUserId); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); await flush; }); it('should create an encrypted DM if supported', () => testCryptoStores(async (cryptoStoreType) => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId, cryptoStoreType); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", `/keys/query`).respond(200, (path, body) => { expect(body).toMatchObject({ device_keys: { [dmUserId]: [], }, }); return { failures: {}, device_keys: { [dmUserId]: { [TEST_DEVICE_ID]: { user_id: dmUserId, device_id: TEST_DEVICE_ID, // mostly unused, but would be a device }, }, }, }; }); // noinspection TypeScriptValidateJSTypes http.when("POST", `/createRoom`).respond(200, (path, body) => { expect(body).toEqual({ invite: [dmUserId], is_direct: true, preset: "trusted_private_chat", initial_state: [{ type: "m.room.encryption", state_key: "", content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 }, }], }); return { room_id: dmRoomId }; }); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ [dmUserId]: [dmRoomId], }); return {}; }); const flush = http.flushAllExpected(); expect(dms.isDm(dmRoomId)).toBe(false); const roomId = await dms.getOrCreateDm(dmUserId); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); await flush; })); it('should create an unencrypted DM when the target user has no devices', () => testCryptoStores(async (cryptoStoreType) => { const selfUserId = "@self:example.org"; const { client, http } = createTestClient(null, selfUserId, cryptoStoreType); const dms = client.dms; const dmRoomId = "!dm:example.org"; const dmUserId = "@target:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", `/keys/query`).respond(200, (path, body) => { expect(body).toMatchObject({ device_keys: { [dmUserId]: [], }, }); return { failures: {}, device_keys: {}, // none! }; }); // noinspection TypeScriptValidateJSTypes http.when("POST", `/createRoom`).respond(200, (path, body) => { expect(body).toEqual({ invite: [dmUserId], is_direct: true, preset: "trusted_private_chat", initial_state: [], }); return { room_id: dmRoomId }; }); // noinspection TypeScriptValidateJSTypes http.when("PUT", `/user/${encodeURIComponent(selfUserId)}/account_data/m.direct`).respond(200, (path, body) => { expect(body).toEqual({ [dmUserId]: [dmRoomId], }); return {}; }); const flush = http.flushAllExpected(); expect(dms.isDm(dmRoomId)).toBe(false); const roomId = await dms.getOrCreateDm(dmUserId); expect(roomId).toEqual(dmRoomId); expect(dms.isDm(dmRoomId)).toBe(true); await flush; })); }); ================================================ FILE: test/IdentityClientTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import * as simple from "simple-mock"; import { IdentityClient, MatrixClient, setRequestFn, Threepid } from "../src"; import { createTestClient } from "./TestUtils"; export async function createTestIdentityClient(): Promise<{ client: IdentityClient, mxClient: MatrixClient, http: HttpBackend, identityUrl: string, accessToken: string }> { const result = createTestClient(); const mxClient = result.client; const idServer = "id.example.org"; const idAccessToken = "t0ken"; result.http.when("POST", "/_matrix/identity/v2/account/register").respond(200, { token: idAccessToken }); mxClient.getOpenIDConnectToken = () => Promise.resolve({ access_token: "s3cret", expires_in: 1200, matrix_server_name: "localhost", token_type: "Bearer", }); const [client] = await Promise.all([mxClient.getIdentityServerClient(idServer), result.http.flushAllExpected()]); delete result.client; delete result.hsUrl; delete result.accessToken; return { ...result, client, mxClient, accessToken: idAccessToken, identityUrl: `https://${idServer}` }; } describe('IdentityClient', () => { describe('getAccount', () => { it('should call the right endpoint', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const accountResponse = { user_id: "@alice:example.org", }; http.when("GET", "/_matrix/identity/v2/account").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/account`); return accountResponse; }); const [resp] = await Promise.all([client.getAccount(), http.flushAllExpected()]); expect(resp).toMatchObject(accountResponse); }); }); describe('getTermsOfService', () => { it('should call the right endpoint', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const response = { policies: { test1: { en: { name: "Test Policy", url: "https://terms.example.org/v1/en/test1", }, version: "1", }, test2: { en: { name: "Test Policy (English)", url: "https://terms.example.org/v1.1/en/test2", }, fr: { name: "Test Policy (French)", url: "https://terms.example.org/v1.1/fr/test2", }, version: "1.1", }, }, }; http.when("GET", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); return response; }); const [result] = await Promise.all([client.getTermsOfService(), http.flushAllExpected()]); expect(result).toEqual(response); }); }); describe('acceptTerms', () => { it('should call the right endpoint', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const urls = ["https://terms.example.org/v1/en/test1", "https://terms.example.org/v1/en/test2"]; http.when("POST", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); expect(content).toMatchObject({ user_accepts: urls }); return {}; }); await Promise.all([client.acceptTerms(urls), http.flushAllExpected()]); }); }); describe('acceptAllTerms', () => { it('should pick English over other languages', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const policies = { policies: { test1: { en: { name: "Test Policy", url: "https://terms.example.org/v1/en/test1", }, version: "1", }, test2: { en: { name: "Test Policy (English)", url: "https://terms.example.org/v1.1/en/test2", }, fr: { name: "Test Policy (French)", url: "https://terms.example.org/v1.1/fr/test2", }, version: "1.1", }, }, }; const urls = ["https://terms.example.org/v1/en/test1", "https://terms.example.org/v1.1/en/test2"]; http.when("GET", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); return policies; }); http.when("POST", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); expect(content).toMatchObject({ user_accepts: urls }); return {}; }); await Promise.all([client.acceptAllTerms(), http.flushAllExpected()]); }); it('should pick other languages if English is not available', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const policies = { policies: { test1: { en: { name: "Test Policy", url: "https://terms.example.org/v1/en/test1", }, version: "1", }, test2: { // en: { // name: "Test Policy (English)", // url: "https://terms.example.org/v1.1/en/test2" // }, fr: { name: "Test Policy (French)", url: "https://terms.example.org/v1.1/fr/test2", }, version: "1.1", }, }, }; const urls = ["https://terms.example.org/v1/en/test1", "https://terms.example.org/v1.1/fr/test2"]; http.when("GET", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); return policies; }); http.when("POST", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); expect(content).toMatchObject({ user_accepts: urls }); return {}; }); await Promise.all([client.acceptAllTerms(), http.flushAllExpected()]); }); it('should ignore invalid policies', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const policies = { policies: { test1: { en: { name: "Test Policy", url: "https://terms.example.org/v1/en/test1", }, version: "1", }, test2: { // en: { // name: "Test Policy (English)", // url: "https://terms.example.org/v1.1/en/test2" // }, // fr: { // name: "Test Policy (French)", // url: "https://terms.example.org/v1.1/fr/test2" // }, version: "1.1", }, }, }; const urls = ["https://terms.example.org/v1/en/test1"]; http.when("GET", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); return policies; }); http.when("POST", "/_matrix/identity/v2/terms").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/terms`); expect(content).toMatchObject({ user_accepts: urls }); return {}; }); await Promise.all([client.acceptAllTerms(), http.flushAllExpected()]); }); }); describe('lookup', () => { it('should call the right endpoint (sha256)', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = ["sha256"]; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; const hashes = [ "4kenr7N9drpCJ4AfalmlGQVsOn3o2RHjkADUpXJWZUc", "nlo35_T5fzSGZzJApqu8lgIudJvmOQtDaHtr-I4rU7I", ]; const mappedUserId = "@alice:example.org"; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); http.when("POST", "/_matrix/identity/v2/lookup").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/lookup`); expect(content).toMatchObject({ pepper: pepper, algorithm: algorithms[0], addresses: hashes, }); return { mappings: { [hashes[0]]: mappedUserId, }, }; }); const [response] = await Promise.all([client.lookup(addresses), http.flushAllExpected()]); expect(Array.isArray(response)).toBe(true); expect(response[0]).toEqual(mappedUserId); expect(response[1]).toBeFalsy(); }); it('should call the right endpoint (none/plaintext)', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = ["none"]; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; const hashes = [ "alice@example.com email", "18005552067 msisdn", ]; const mappedUserId = "@alice:example.org"; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); http.when("POST", "/_matrix/identity/v2/lookup").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/lookup`); expect(content).toMatchObject({ pepper: pepper, algorithm: algorithms[0], addresses: hashes, }); return { mappings: { [hashes[0]]: mappedUserId, }, }; }); const [response] = await Promise.all([client.lookup(addresses, true), http.flushAllExpected()]); expect(Array.isArray(response)).toBe(true); expect(response[0]).toEqual(mappedUserId); expect(response[1]).toBeFalsy(); }); it('should prefer hashing over plaintext', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = ["none", "sha256"]; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; const hashes = [ "4kenr7N9drpCJ4AfalmlGQVsOn3o2RHjkADUpXJWZUc", "nlo35_T5fzSGZzJApqu8lgIudJvmOQtDaHtr-I4rU7I", ]; const mappedUserId = "@alice:example.org"; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); http.when("POST", "/_matrix/identity/v2/lookup").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/lookup`); expect(content).toMatchObject({ pepper: pepper, algorithm: algorithms[1], addresses: hashes, }); return { mappings: { [hashes[0]]: mappedUserId, }, }; }); const [response] = await Promise.all([client.lookup(addresses), http.flushAllExpected()]); expect(Array.isArray(response)).toBe(true); expect(response[0]).toEqual(mappedUserId); expect(response[1]).toBeFalsy(); }); it('should prefer hashing over plaintext, even if allowed', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = ["none", "sha256"]; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; const hashes = [ "4kenr7N9drpCJ4AfalmlGQVsOn3o2RHjkADUpXJWZUc", "nlo35_T5fzSGZzJApqu8lgIudJvmOQtDaHtr-I4rU7I", ]; const mappedUserId = "@alice:example.org"; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); http.when("POST", "/_matrix/identity/v2/lookup").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/lookup`); expect(content).toMatchObject({ pepper: pepper, algorithm: algorithms[1], addresses: hashes, }); return { mappings: { [hashes[0]]: mappedUserId, }, }; }); const [response] = await Promise.all([client.lookup(addresses, true), http.flushAllExpected()]); expect(Array.isArray(response)).toBe(true); expect(response[0]).toEqual(mappedUserId); expect(response[1]).toBeFalsy(); }); it('should fail if no algorithms are present', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = []; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); try { await Promise.all([client.lookup(addresses), http.flushAllExpected()]); throw new Error("Failed to fail"); } catch (e) { expect(e.message === "No supported hashing algorithm found"); } }); it('should fail if no relevant algorithms are present', async () => { const { client, http, identityUrl } = await createTestIdentityClient(); const algorithms = ["io.t2bot.example.custom"]; const pepper = "matrixrocks"; const addresses: Threepid[] = [ { kind: "email", address: "alice@example.com" }, { kind: "msisdn", address: "18005552067" }, ]; http.when("GET", "/_matrix/identity/v2/hash_details").respond(200, (path, content) => { expect(path).toEqual(`${identityUrl}/_matrix/identity/v2/hash_details`); return { algorithms: algorithms, lookup_pepper: pepper, }; }); try { await Promise.all([client.lookup(addresses), http.flushAllExpected()]); throw new Error("Failed to fail"); } catch (e) { expect(e.message === "No supported hashing algorithm found"); } }); }); describe("doRequest", () => { it('should use the request function defined', async () => { const { client } = await createTestIdentityClient(); const testFn = ((_, cb) => cb(null, { statusCode: 200 })); const spy = simple.spy(testFn); setRequestFn(spy); await client.doRequest("GET", "/test"); expect(spy.callCount).toBe(1); }); it('should reject upon error', async () => { const { client, http } = await createTestIdentityClient(); http.when("GET", "/test").respond(404, { error: "Not Found" }); try { await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected an error and didn't get one"); } catch (e) { expect(e.statusCode).toBe(404); } }); it('should return a parsed JSON body', async () => { const { client, http } = await createTestIdentityClient(); const expectedResponse = { test: 1234 }; http.when("GET", "/test").respond(200, expectedResponse); const [response] = await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); expect(response).toMatchObject(expectedResponse); }); it('should be kind with prefixed slashes', async () => { const { client, http } = await createTestIdentityClient(); const expectedResponse = { test: 1234 }; http.when("GET", "/test").respond(200, expectedResponse); const [response] = await Promise.all([client.doRequest("GET", "test"), http.flushAllExpected()]); expect(response).toMatchObject(expectedResponse); }); it('should send the appropriate body', async () => { const { client, http } = await createTestIdentityClient(); const expectedInput = { test: 1234 }; http.when("PUT", "/test").respond(200, (path, content) => { expect(content).toMatchObject(expectedInput); return {}; }); await Promise.all([client.doRequest("PUT", "/test", null, expectedInput), http.flushAllExpected()]); }); it('should send the appropriate query string', async () => { const { client, http } = await createTestIdentityClient(); const expectedInput = { test: 1234 }; http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams).toMatchObject(expectedInput); return {}; }); await Promise.all([client.doRequest("GET", "/test", expectedInput), http.flushAllExpected()]); }); it('should send the access token in the Authorization header', async () => { const { client, http, accessToken } = await createTestIdentityClient(); http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.headers["Authorization"]).toEqual(`Bearer ${accessToken}`); return {}; }); await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should send application/json by default', async () => { const { client, http } = await createTestIdentityClient(); http.when("PUT", "/test").respond(200, (path, content, req) => { expect(req.headers["Content-Type"]).toEqual("application/json"); return {}; }); await Promise.all([client.doRequest("PUT", "/test", null, { test: 1 }), http.flushAllExpected()]); }); it('should send the content-type of choice where possible', async () => { const { client, http } = await createTestIdentityClient(); const contentType = "testing/type"; const fakeJson = `{"BUFFER": "HACK"}`; Buffer.isBuffer = (i => i === fakeJson); http.when("PUT", "/test").respond(200, (path, content, req) => { expect(req.headers["Content-Type"]).toEqual(contentType); return {}; }); await Promise.all([ client.doRequest("PUT", "/test", null, fakeJson, 60000, false, contentType), http.flushAllExpected(), ]); }); it('should return raw responses if requested', async () => { const { client, http } = await createTestIdentityClient(); const expectedOutput = { hello: "world" }; http.when("PUT", "/test").respond(200, expectedOutput); const [result] = await Promise.all([ client.doRequest("PUT", "/test", null, {}, 60000, true), http.flushAllExpected(), ]); // HACK: We can't check the body because of the mock library. Check the status code instead. expect(result.statusCode).toBe(200); }); it('should proxy the timeout to request', async () => { const { client, http } = await createTestIdentityClient(); const timeout = 10; http.when("GET", "/test").respond(200, (path, content, req) => { expect((req as any).opts.timeout).toBe(timeout); }); await Promise.all([client.doRequest("GET", "/test", null, null, timeout), http.flushAllExpected()]); }); }); describe('makeEmailInvite', () => { it('should call the right endpoint', async () => { const { client, http } = await createTestIdentityClient(); const mxUserId = "@bob:example.org"; client.matrixClient.getUserId = () => Promise.resolve(mxUserId); const inviteEmail = "alice@example.org"; const inviteRoomId = "!room:example.org"; const storedInvite = { display_name: "a...@e...", public_keys: [ "serverkey", "ephemeralkey", ], token: "s3cret", }; const stateStub = () => Promise.resolve(null); client.matrixClient.getRoomStateEvent = stateStub; client.matrixClient.getUserProfile = stateStub; http.when("POST", "/_matrix/identity/v2/store-invite").respond(200, (path, content) => { expect(content).toMatchObject({ address: inviteEmail, room_id: inviteRoomId, medium: "email", sender: mxUserId, }); return storedInvite; }); const [resp] = await Promise.all([client.makeEmailInvite(inviteEmail, inviteRoomId), http.flushAllExpected()]); expect(resp).toMatchObject(storedInvite); }); it('should request room state events and user profile', async () => { const { client, http } = await createTestIdentityClient(); const mxUserId = "@bob:example.org"; client.matrixClient.getUserId = () => Promise.resolve(mxUserId); const inviteEmail = "alice@example.org"; const inviteRoomId = "!room:example.org"; const inviteRoomName = "Test Room"; const inviteRoomAvatar = "mxc://example.org/roomavatar"; const inviteRoomJoinRules = "public"; const inviteRoomAlias = "#test:example.org"; const senderDisplayName = "Bob Test"; const senderAvatarUrl = "mxc://example.org/avatar"; const storedInvite = { display_name: "a...@e...", public_keys: [ { public_key: "serverkey", key_validity_url: "/_matrix/identity/v1/pubkey/isvalid" }, { public_key: "ephemeralkey", key_validity_url: "/_matrix/identity/v1/pubkey/isvalid" }, ], public_key: "serverkey", token: "s3cret", }; const expectedStateEvents = [ "m.room.canonical_alias", "m.room.name", "m.room.avatar", "m.room.join_rules", ]; const calledStateEvents: string[] = []; const stateStub = async (roomId: string, evType: string, stateKey: string) => { expect(roomId).toBe(inviteRoomId); expect(stateKey).toBe(""); calledStateEvents.push(evType); switch (evType) { case "m.room.name": return { name: inviteRoomName }; case "m.room.canonical_alias": return { alias: inviteRoomAlias }; case "m.room.join_rules": return { join_rule: inviteRoomJoinRules }; case "m.room.avatar": return { url: inviteRoomAvatar }; default: throw new Error("Unknown event type"); } }; client.matrixClient.getRoomStateEvent = stateStub; const profileSpy = simple.mock(client.matrixClient, "getUserProfile").callFn(() => { return Promise.resolve({ displayname: senderDisplayName, avatar_url: senderAvatarUrl }); }); http.when("POST", "/_matrix/identity/v2/store-invite").respond(200, (path, content) => { expect(content).toMatchObject({ address: inviteEmail, room_id: inviteRoomId, medium: "email", sender: mxUserId, sender_avatar_url: senderAvatarUrl, sender_display_name: senderDisplayName, room_alias: inviteRoomAlias, room_avatar_url: inviteRoomAvatar, room_join_rules: inviteRoomJoinRules, room_name: inviteRoomName, }); return storedInvite; }); const [resp] = await Promise.all([client.makeEmailInvite(inviteEmail, inviteRoomId), http.flushAllExpected()]); expect(resp).toMatchObject(storedInvite); expect(profileSpy.callCount).toBe(1); expect({ calledStateEvents }).toMatchObject({ calledStateEvents: expectedStateEvents }); }); it('should use the canonical alias when no explicit name is present', async () => { const { client, http } = await createTestIdentityClient(); const mxUserId = "@bob:example.org"; client.matrixClient.getUserId = () => Promise.resolve(mxUserId); const inviteEmail = "alice@example.org"; const inviteRoomId = "!room:example.org"; const inviteRoomAvatar = "mxc://example.org/roomavatar"; const inviteRoomJoinRules = "public"; const inviteRoomAlias = "#test:example.org"; const senderDisplayName = "Bob Test"; const senderAvatarUrl = "mxc://example.org/avatar"; const storedInvite = { display_name: "a...@e...", public_keys: [ { public_key: "serverkey", key_validity_url: "/_matrix/identity/v1/pubkey/isvalid" }, { public_key: "ephemeralkey", key_validity_url: "/_matrix/identity/v1/pubkey/isvalid" }, ], public_key: "serverkey", token: "s3cret", }; const expectedStateEvents = [ "m.room.canonical_alias", "m.room.name", "m.room.avatar", "m.room.join_rules", ]; const calledStateEvents: string[] = []; const stateStub = async (roomId: string, evType: string, stateKey: string) => { expect(roomId).toBe(inviteRoomId); expect(stateKey).toBe(""); calledStateEvents.push(evType); switch (evType) { case "m.room.name": throw new Error("ROOM_NAME: Not found"); case "m.room.canonical_alias": return { alias: inviteRoomAlias }; case "m.room.join_rules": return { join_rule: inviteRoomJoinRules }; case "m.room.avatar": return { url: inviteRoomAvatar }; default: throw new Error("Unknown event type"); } }; client.matrixClient.getRoomStateEvent = stateStub; const profileSpy = simple.mock(client.matrixClient, "getUserProfile").callFn(() => { return Promise.resolve({ displayname: senderDisplayName, avatar_url: senderAvatarUrl }); }); http.when("POST", "/_matrix/identity/v2/store-invite").respond(200, (path, content) => { expect(content).toMatchObject({ address: inviteEmail, room_id: inviteRoomId, medium: "email", sender: mxUserId, sender_avatar_url: senderAvatarUrl, sender_display_name: senderDisplayName, room_alias: inviteRoomAlias, room_avatar_url: inviteRoomAvatar, room_join_rules: inviteRoomJoinRules, room_name: inviteRoomAlias, // !! This is what we're testing }); return storedInvite; }); const [resp] = await Promise.all([client.makeEmailInvite(inviteEmail, inviteRoomId), http.flushAllExpected()]); expect(resp).toMatchObject(storedInvite); expect(profileSpy.callCount).toBe(1); expect({ calledStateEvents }).toMatchObject({ calledStateEvents: expectedStateEvents }); }); }); }); ================================================ FILE: test/MatrixAuthTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import { MatrixAuth } from "../src"; import { createTestClient } from "./TestUtils"; export function createTestAuth(): { auth: MatrixAuth, http: HttpBackend, hsUrl: string } { const result = createTestClient(); const mxClient = result.client; const hsUrl = result.hsUrl; const http = result.http; const auth = new MatrixAuth(hsUrl); // Overwrite the function for which client to return. We want to use the // one which uses our http thing. auth['createTemplateClient'] = () => mxClient; return { hsUrl, http, auth }; } describe('MatrixAuth', () => { describe('passwordRegister', () => { it('should call the right endpoint', async () => { const { auth, http, hsUrl } = createTestAuth(); const username = "testing_username"; const password = "P@ssw0rd"; const accessToken = "1234"; http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ username, password }); return { access_token: accessToken }; }); const [client] = await Promise.all([auth.passwordRegister(username, password), http.flushAllExpected()]); expect(client.homeserverUrl).toEqual(hsUrl); expect(client.accessToken).toEqual(accessToken); }); // TODO: Enable test. // We can't test this currently because matrix-mock-request doesn't support sending the response // object for errors. xit('should support UIA', async () => { const { auth, http, hsUrl } = createTestAuth(); const username = "testing_username"; const password = "P@ssw0rd"; const accessToken = "1234"; const sessionId = "5678"; // First is UIA http.when("POST", "/_matrix/client/v3/register").respond(401, (path, content) => { expect(content).toMatchObject({ username, password }); return { session: sessionId, flows: [ { stages: ["m.login.dummy"] }, ], params: {}, }; }); http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ username, password, auth: { type: "m.login.dummy", session: sessionId, }, }); return { access_token: accessToken }; }); const [client] = await Promise.all([auth.passwordRegister(username, password), http.flushAllExpected()]); expect(client.homeserverUrl).toEqual(hsUrl); expect(client.accessToken).toEqual(accessToken); }); }); describe('passwordLogin', () => { it('should call the right endpoint', async () => { const { auth, http, hsUrl } = createTestAuth(); const username = "testing_username"; const password = "P@ssw0rd"; const accessToken = "1234"; http.when("POST", "/_matrix/client/v3/login").respond(200, (path, content) => { expect(content).toMatchObject({ type: "m.login.password", identifier: { type: "m.id.user", user: username, }, password, }); return { access_token: accessToken }; }); const [client] = await Promise.all([auth.passwordLogin(username, password), http.flushAllExpected()]); expect(client.homeserverUrl).toEqual(hsUrl); expect(client.accessToken).toEqual(accessToken); }); }); }); ================================================ FILE: test/MatrixClientTest.ts ================================================ import * as tmp from "tmp"; import * as simple from "simple-mock"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { EventKind, IJoinRoomStrategy, IPreprocessor, IToDeviceMessage, MatrixClient, Membership, MemoryStorageProvider, OpenIDConnectToken, OTKAlgorithm, OTKCounts, OTKs, PowerLevelAction, redactObjectForLogging, RoomCreateOptions, RoomDirectoryLookupResponse, RoomEvent, RustSdkCryptoStorageProvider, ServerVersions, setRequestFn, } from "../src"; import { createTestClient, expectArrayEquals, testCryptoStores, TEST_DEVICE_ID } from "./TestUtils"; tmp.setGracefulCleanup(); describe('MatrixClient', () => { describe("constructor", () => { it('should pass through the homeserver URL and access token', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; const client = new MatrixClient(homeserverUrl, accessToken); expect(client.homeserverUrl).toEqual(homeserverUrl); expect(client.accessToken).toEqual(accessToken); }); it('should strip trailing slashes from the homeserver URL', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; const client = new MatrixClient(homeserverUrl + "/", accessToken); expect(client.homeserverUrl).toEqual(homeserverUrl); expect(client.accessToken).toEqual(accessToken); }); it('should create a crypto client when requested', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; const client = new MatrixClient(homeserverUrl, accessToken, null, new RustSdkCryptoStorageProvider(tmp.dirSync().name, StoreType.Sqlite)); expect(client.crypto).toBeDefined(); }); it('should NOT create a crypto client when requested', () => { const homeserverUrl = "https://example.org"; const accessToken = "example_token"; const client = new MatrixClient(homeserverUrl, accessToken, null, null); expect(client.crypto).toBeUndefined(); }); }); describe("doRequest", () => { it('should use the request function defined', async () => { const { client } = createTestClient(); const testFn = ((_, cb) => cb(null, { statusCode: 200 })); const spy = simple.spy(testFn); setRequestFn(spy); await client.doRequest("GET", "/test"); expect(spy.callCount).toBe(1); }); it('should reject upon error', async () => { const { client, http } = createTestClient(); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(404, { error: "Not Found" }); try { await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected an error and didn't get one"); } catch (e) { expect(e.statusCode).toBe(404); } }); it('should return a parsed JSON body', async () => { const { client, http } = createTestClient(); const expectedResponse = { test: 1234 }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, expectedResponse); const [response] = await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); expect(response).toMatchObject(expectedResponse); }); it('should be kind with prefixed slashes', async () => { const { client, http } = createTestClient(); const expectedResponse = { test: 1234 }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, expectedResponse); const [response] = await Promise.all([client.doRequest("GET", "test"), http.flushAllExpected()]); expect(response).toMatchObject(expectedResponse); }); it('should send the appropriate body', async () => { const { client, http } = createTestClient(); const expectedInput = { test: 1234 }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/test").respond(200, (path, content) => { expect(content).toMatchObject(expectedInput); return {}; }); await Promise.all([client.doRequest("PUT", "/test", null, expectedInput), http.flushAllExpected()]); }); it('should send the appropriate query string', async () => { const { client, http } = createTestClient(); const expectedInput = { test: 1234 }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams).toMatchObject(expectedInput); return {}; }); await Promise.all([client.doRequest("GET", "/test", expectedInput), http.flushAllExpected()]); }); it('should send the access token in the Authorization header', async () => { const { client, http, accessToken } = createTestClient(); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.headers["Authorization"]).toEqual(`Bearer ${accessToken}`); return {}; }); await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should send application/json by default', async () => { const { client, http } = createTestClient(); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/test").respond(200, (path, content, req) => { expect(req.headers["Content-Type"]).toEqual("application/json"); return {}; }); await Promise.all([client.doRequest("PUT", "/test", null, { test: 1 }), http.flushAllExpected()]); }); it('should send the content-type of choice where possible', async () => { const { client, http } = createTestClient(); const contentType = "testing/type"; const fakeJson = `{"BUFFER": "HACK"}`; Buffer.isBuffer = (i => i === fakeJson); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/test").respond(200, (path, content, req) => { expect(req.headers["Content-Type"]).toEqual(contentType); return {}; }); await Promise.all([ client.doRequest("PUT", "/test", null, fakeJson, 60000, false, contentType), http.flushAllExpected(), ]); }); it('should return raw responses if requested', async () => { const { client, http } = createTestClient(); const expectedOutput = { hello: "world" }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/test").respond(200, expectedOutput); const [result] = await Promise.all([ client.doRequest("PUT", "/test", null, {}, 60000, true), http.flushAllExpected(), ]); // HACK: We can't check the body because of the mock library. Check the status code instead. expect(result.statusCode).toBe(200); }); it('should proxy the timeout to request', async () => { const { client, http } = createTestClient(); const timeout = 10; // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect((req as any).opts.timeout).toBe(timeout); }); await Promise.all([client.doRequest("GET", "/test", null, null, timeout), http.flushAllExpected()]); }); }); describe('impersonateUserId', () => { it('should set a user_id param on requests', async () => { const { client, http } = createTestClient(); const userId = "@testing:example.org"; client.impersonateUserId(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams["user_id"]).toBe(userId); expect(req.queryParams["org.matrix.msc3202.device_id"]).toBe(undefined); }); await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should set a device_id param on requests', async () => { const { client, http } = createTestClient(); const userId = "@testing:example.org"; const deviceId = "DEVICE_TEST"; client.impersonateUserId(userId, deviceId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams["user_id"]).toBe(userId); expect(req.queryParams["org.matrix.msc3202.device_id"]).toBe(deviceId); }); await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should stop impersonation with a null user_id', async () => { const { client, http } = createTestClient(); const userId = "@testing:example.org"; client.impersonateUserId(userId); // set first client.impersonateUserId(null); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams?.["user_id"]).toBe(undefined); expect(req.queryParams?.["org.matrix.msc3202.device_id"]).toBe(undefined); }); await Promise.all([client.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should not allow impersonation of only a device ID', async () => { const { client } = createTestClient(); try { client.impersonateUserId(null, "DEVICE"); } catch (e) { expect(e.message).toBe("Cannot impersonate just a device: need a user ID"); } }); }); describe('unstableApis', () => { it('should always return an object', async () => { const { client } = createTestClient(); const result = client.unstableApis; expect(result).toBeDefined(); }); }); describe('adminApis', () => { it('should always return an object', async () => { const { client } = createTestClient(); const result = client.adminApis; expect(result).toBeDefined(); }); }); describe('dms', () => { it('should always return an object', async () => { const { client } = createTestClient(); const result = client.dms; expect(result).toBeDefined(); }); }); describe('getServerVersions', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const versionsResponse: ServerVersions = { unstable_features: { "org.example.feature1": true, "org.example.feature2": false, }, versions: ["r0.6.1", "r0.5.0", "v1.1", "v1.2"], }; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result] = await Promise.all([client.getServerVersions(), http.flushAllExpected()]); expect(result).toEqual(versionsResponse); }); it('should cache the response', async () => { const { client, http } = createTestClient(); const versionsResponse: ServerVersions = { unstable_features: { "org.example.feature1": true, "org.example.feature2": false, }, versions: ["r0.6.1", "r0.5.0", "v1.1", "v1.2"], }; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result] = await Promise.all([client.getServerVersions(), http.flushAllExpected()]); expect(result).toEqual(versionsResponse); const result2 = await client.getServerVersions(); expect(result2).toBe(result); // looking for same pointer // clear the timer with private member access (client).versionsLastFetched = 0; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result3] = await Promise.all([client.getServerVersions(), http.flushAllExpected()]); expect(result3).toEqual(versionsResponse); }); }); describe('doesServerSupportUnstableFeature', () => { test.each(<[ServerVersions["unstable_features"], string, boolean][]>[ [null, "org.example.feature", false], [undefined, "org.example.feature", false], [{}, "org.example.feature", false], [{ "org.example.feature": true }, "org.example.feature", true], [{ "org.example.feature": false }, "org.example.feature", false], [{ "org.example.wrong": true }, "org.example.feature", false], [{ "org.example.wrong": false }, "org.example.feature", false], ])("should find that %p has %p as %p", async (versions, flag, target) => { const { client, http } = createTestClient(); const versionsResponse: ServerVersions = { versions: ["v1.1"], unstable_features: versions, }; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result] = await Promise.all([client.doesServerSupportUnstableFeature(flag), http.flushAllExpected()]); expect(result).toEqual(target); }); }); describe('doesServerSupportVersion', () => { test.each(<[ServerVersions["versions"], string, boolean][]>[ [[], "v1.1", false], [["v1.2"], "v1.1", false], [["v1.1", "v1.2", "v1.3"], "v1.2", true], ])("should find that %p has %p as %p", async (versions, version, target) => { const { client, http } = createTestClient(); const versionsResponse: ServerVersions = { versions: versions, }; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result] = await Promise.all([client.doesServerSupportVersion(version), http.flushAllExpected()]); expect(result).toEqual(target); }); }); describe('doesServerSupportAnyOneVersion', () => { test.each(<[ServerVersions["versions"], string[], boolean][]>[ [[], ["v1.1", "v1.2"], false], [["v1.3"], ["v1.1", "v1.2"], false], [["v1.1", "v1.2", "v1.3"], ["v1.2", "v1.3"], true], ])("should find that %p has %p as %p", async (versions, searchVersions, target) => { const { client, http } = createTestClient(); const versionsResponse: ServerVersions = { versions: versions, }; http.when("GET", "/_matrix/client/versions").respond(200, versionsResponse); const [result] = await Promise.all([client.doesServerSupportAnyOneVersion(searchVersions), http.flushAllExpected()]); expect(result).toEqual(target); }); }); describe('getOpenIDConnectToken', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const testToken: OpenIDConnectToken = { access_token: "s3cret", expires_in: 1200, matrix_server_name: "localhost", token_type: "Bearer", }; const userId = "@test:example.org"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/user").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`); return testToken; }); const [r] = await Promise.all([client.getOpenIDConnectToken(), http.flushAllExpected()]); expect(r).toMatchObject(testToken); // to fix typescript }); }); describe('getIdentityServerClient', () => { // This doubles as the test for IdentityClient#acquire() it('should prepare an identity server client', async () => { const { client, http } = createTestClient(); const testToken: OpenIDConnectToken = { access_token: "s3cret", expires_in: 1200, matrix_server_name: "localhost", token_type: "Bearer", }; const userId = "@test:example.org"; const identityDomain = "identity.example.org"; const identityToken = "t0ken"; client.getUserId = () => Promise.resolve(userId); client.getOpenIDConnectToken = () => Promise.resolve(testToken); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/identity/v2/account").respond(200, (path) => { expect(path).toEqual(`https://${identityDomain}/_matrix/identity/v2/account/register`); return { token: identityToken }; }); const [iClient] = await Promise.all([client.getIdentityServerClient(identityDomain), http.flushAllExpected()]); expect(iClient).toBeDefined(); }); }); describe('getAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const userId = "@test:example.org"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${encodeURIComponent(eventType)}`); return {}; }); await Promise.all([client.getAccountData(eventType), http.flushAllExpected()]); }); }); describe('getSafeAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const userId = "@test:example.org"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${encodeURIComponent(eventType)}`); return {}; }); await Promise.all([client.getSafeAccountData(eventType), http.flushAllExpected()]); }); it('should return the default on error', async () => { const { client, http } = createTestClient(); const eventType = "io.t2bot.test.data"; const userId = "@test:example.org"; const defaultContent = { hello: "world" }; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(404, {}); const [ret] = await Promise.all([client.getSafeAccountData(eventType, defaultContent), http.flushAllExpected()]); expect(ret).toBe(defaultContent); }); }); describe('getPresenceStatus', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@test:example.org"; const presenceObj = { presence: "online", last_active_ago: 12, status_msg: "Hello world", currently_active: true, }; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/presence").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`); return presenceObj; }); const [result] = await Promise.all([client.getPresenceStatus(), http.flushAllExpected()]); expect(result).toBeDefined(); // The shape of the object is handled by other tests }); }); describe('getPresenceStatusFor', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@testing:example.org"; const presenceObj = { presence: "online", last_active_ago: 12, status_msg: "Hello world", currently_active: true, }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/presence").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`); return presenceObj; }); const [result] = await Promise.all([client.getPresenceStatusFor(userId), http.flushAllExpected()]); expect(result).toBeDefined(); // The shape of the object is handled by other tests }); }); describe('setPresenceStatus', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@test:example.org"; const presence = "online"; const message = "Hello World"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/presence").respond(200, (path, obj) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`); expect(obj).toMatchObject({ presence: presence, status_msg: message, }); return {}; }); await Promise.all([client.setPresenceStatus(presence, message), http.flushAllExpected()]); }); it('should not send status_msg if the parameter is omitted', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@test:example.org"; const presence = "online"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/presence").respond(200, (path, obj) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`); expect(obj).toEqual({ presence: presence, }); return {}; }); await Promise.all([client.setPresenceStatus(presence), http.flushAllExpected()]); }); }); describe('getRoomAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const roomId = "!test:example.org"; const userId = "@test:example.org"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(200, (path) => { // eslint-disable-next-line max-len expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent(roomId)}/account_data/${encodeURIComponent(eventType)}`); return {}; }); await Promise.all([client.getRoomAccountData(eventType, roomId), http.flushAllExpected()]); }); }); describe('getSafeRoomAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const roomId = "!test:example.org"; const userId = "@test:example.org"; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(200, (path) => { // eslint-disable-next-line max-len expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent(roomId)}/account_data/${encodeURIComponent(eventType)}`); return {}; }); await Promise.all([client.getSafeRoomAccountData(eventType, roomId), http.flushAllExpected()]); }); it('should return the default on error', async () => { const { client, http } = createTestClient(); const eventType = "io.t2bot.test.data"; const roomId = "!test:example.org"; const userId = "@test:example.org"; const defaultContent = { hello: "world" }; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/user").respond(404, {}); const [ret] = await Promise.all([client.getSafeRoomAccountData(eventType, roomId, defaultContent), http.flushAllExpected()]); expect(ret).toBe(defaultContent); }); }); describe('setAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const userId = "@test:example.org"; const eventContent = { test: 123 }; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/user").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${encodeURIComponent(eventType)}`); expect(content).toMatchObject(eventContent); return {}; }); await Promise.all([client.setAccountData(eventType, eventContent), http.flushAllExpected()]); }); }); describe('setRoomAccountData', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const eventType = "io.t2bot.test.data"; const roomId = "!test:example.org"; const userId = "@test:example.org"; const eventContent = { test: 123 }; client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/user").respond(200, (path, content) => { // eslint-disable-next-line max-len expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent(roomId)}/account_data/${encodeURIComponent(eventType)}`); expect(content).toMatchObject(eventContent); return {}; }); await Promise.all([client.setRoomAccountData(eventType, roomId, eventContent), http.flushAllExpected()]); }); }); describe('getPublishedAlias', () => { it('should return falsey on 404', async () => { const { client, http } = createTestClient(); const roomId = "!abc:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms/").respond(404, {}); const [published] = await Promise.all([client.getPublishedAlias(roomId), http.flushAllExpected()]); expect(published).toBeFalsy(); }); it('should return falsey on no aliases (empty content)', async () => { const { client, http } = createTestClient(); const roomId = "!abc:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms/").respond(200, {}); const [published] = await Promise.all([client.getPublishedAlias(roomId), http.flushAllExpected()]); expect(published).toBeFalsy(); }); it('should return the canonical alias where possible', async () => { const { client, http } = createTestClient(); const roomId = "!abc:example.org"; const alias1 = "#test1:example.org"; const alias2 = "#test2:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms/").respond(200, { alias: alias1, alt_aliases: [alias2], }); const [published] = await Promise.all([client.getPublishedAlias(roomId), http.flushAllExpected()]); expect(published).toEqual(alias1); }); it('should return the first alt alias where possible', async () => { const { client, http } = createTestClient(); const roomId = "!abc:example.org"; const alias1 = "#test1:example.org"; const alias2 = "#test2:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms/").respond(200, { alt_aliases: [alias2, alias1], }); const [published] = await Promise.all([client.getPublishedAlias(roomId), http.flushAllExpected()]); expect(published).toEqual(alias2); }); }); describe('createRoomAlias', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const alias = "#test:example.org"; const roomId = "!abc:example.org"; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/directory/room/").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`); expect(content).toMatchObject({ room_id: roomId }); return {}; }); await Promise.all([client.createRoomAlias(alias, roomId), http.flushAllExpected()]); }); }); describe('deleteRoomAlias', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const alias = "#test:example.org"; // noinspection TypeScriptValidateJSTypes http.when("DELETE", "/_matrix/client/v3/directory/room/").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`); return {}; }); await Promise.all([client.deleteRoomAlias(alias), http.flushAllExpected()]); }); }); describe('setDirectoryVisibility', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!test:example.org"; const visibility = "public"; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/directory/list/room/").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/list/room/${encodeURIComponent(roomId)}`); expect(content).toMatchObject({ visibility: visibility }); return {}; }); await Promise.all([client.setDirectoryVisibility(roomId, visibility), http.flushAllExpected()]); }); }); describe('getDirectoryVisibility', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!test:example.org"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/directory/list/room/").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/list/room/${encodeURIComponent(roomId)}`); return {}; }); await Promise.all([client.getDirectoryVisibility(roomId), http.flushAllExpected()]); }); it('should return the right visibility string', async () => { const { client, http } = createTestClient(); const roomId = "!test:example.org"; const visibility = "public"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/directory/list/room/").respond(200, { visibility: visibility }); const [result] = await Promise.all([client.getDirectoryVisibility(roomId), http.flushAllExpected()]); expect(result).toEqual(visibility); }); }); describe('resolveRoom', () => { it('should return the raw room ID if given an ID', async () => { const { client } = createTestClient(); const roomId = "!test:example.org"; const result = await client.resolveRoom(roomId); expect(result).toEqual(roomId); }); it('should try to look up room aliases', async () => { const { client } = createTestClient(); const roomId = "!abc123:example.org"; const alias = "#test:example.org"; const spy = simple.stub().returnWith(new Promise(((resolve) => resolve({ roomId: roomId, residentServers: [], })))); client.lookupRoomAlias = spy; const result = await client.resolveRoom(alias); expect(result).toEqual(roomId); expect(spy.callCount).toBe(1); }); it('should error on invalid identifiers', async () => { const { client } = createTestClient(); try { await client.resolveRoom("NOT A ROOM"); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to throw an error on an invalid ID"); } catch (e) { expect(e.message).toEqual("Invalid room ID or alias"); } }); }); describe('lookupRoomAlias', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const alias = "#test:example.org"; const servers = ["example.org", "localhost"]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/directory/room/").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`); return { room_id: roomId, servers: servers }; }); await Promise.all([client.lookupRoomAlias(alias), http.flushAllExpected()]); }); it('should return a translated response', async () => { const { client, http } = createTestClient(); const roomId = "!abc123:example.org"; const alias = "#test:example.org"; const servers = ["example.org", "localhost"]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/directory/room/").respond(200, { room_id: roomId, servers: servers }); const [result] = await Promise.all([client.lookupRoomAlias(alias), http.flushAllExpected()]); expect(result).toMatchObject({ roomId: roomId, residentServers: servers }); }); }); describe('inviteUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`); expect(content).toMatchObject({ user_id: userId }); return {}; }); await Promise.all([client.inviteUser(userId, roomId), http.flushAllExpected()]); }); }); describe('kickUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`); expect(content).toMatchObject({ user_id: userId }); return {}; }); await Promise.all([client.kickUser(userId, roomId), http.flushAllExpected()]); }); it('should support a reason', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; const reason = "Excessive unit testing"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`); expect(content).toMatchObject({ user_id: userId, reason: reason }); return {}; }); await Promise.all([client.kickUser(userId, roomId, reason), http.flushAllExpected()]); }); }); describe('banUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/ban`); expect(content).toMatchObject({ user_id: userId }); return {}; }); await Promise.all([client.banUser(userId, roomId), http.flushAllExpected()]); }); it('should support a reason', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; const reason = "Excessive unit testing"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/ban`); expect(content).toMatchObject({ user_id: userId, reason: reason }); return {}; }); await Promise.all([client.banUser(userId, roomId, reason), http.flushAllExpected()]); }); }); describe('unbanUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const userId = "@example:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/unban`); expect(content).toMatchObject({ user_id: userId }); return {}; }); await Promise.all([client.unbanUser(userId, roomId), http.flushAllExpected()]); }); }); describe('getUserId', () => { it('should return the user ID if it is already known', async () => { const { client } = createTestClient(); const userId = "@example:example.org"; (client).userId = userId; const result = await client.getUserId(); expect(result).toEqual(userId); }); it('should request the user ID if it is not known', async () => { const { client, http } = createTestClient(); const userId = "@example:example.org"; const response = { user_id: userId, device_id: "DEVICE", }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/account/whoami").respond(200, response); const [result] = await Promise.all([client.getUserId(), http.flushAllExpected()]); expect(result).toEqual(userId); }); }); describe('getWhoAmI', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const response = { user_id: "@user:example.org", device_id: "DEVICE", }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/account/whoami").respond(200, response); const [result] = await Promise.all([client.getWhoAmI(), http.flushAllExpected()]); expect(result).toMatchObject(response); }); }); describe('stop', () => { it('should stop when requested', async () => { const { client, http } = createTestClient(); (client).userId = "@not_used:example.org"; // to prevent calls to /whoami const max = 5; let count = 0; const dmsUpdate = simple.stub(); client.dms.update = dmsUpdate; // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); const waitPromise = new Promise((resolve) => { for (let i = 0; i <= max * 2; i++) { // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, () => { expect(count).toBeLessThan(max + 1); count++; if (count === max) { client.stop(); // Wait a bit to ensure the client doesn't call /sync anymore setTimeout(resolve, 3000); } return { next_batch: "123" }; }); } }); const flush = http.flushAllExpected().catch(() => false); await client.start(); expect(count).toBeLessThan(max); await waitPromise; expect(count).toBe(max); expect(dmsUpdate.callCount).toBe(1); await flush; client.stop(); }, 10000); }); describe('start', () => { it('should use an existing filter if one is present', async () => { const storage = new MemoryStorageProvider(); const { client, http } = createTestClient(storage); const dmsMock = simple.stub(); client.dms.update = dmsMock; (client).userId = "@notused:example.org"; // to prevent calls to /whoami const filter = { rooms: { limit: 12 } }; simple.mock(storage, "getFilter").returnWith({ id: 12, filter: filter }); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, () => { client.stop(); return { next_batch: "123" }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(dmsMock.callCount).toBe(1); client.stop(); }); it('should create a filter when the stored filter is outdated', async () => { const storage = new MemoryStorageProvider(); const { client, http, hsUrl } = createTestClient(storage); const dmsMock = simple.stub(); client.dms.update = dmsMock; const userId = "@testuser:example.org"; (client).userId = userId; // to prevent calls to /whoami const filter = { rooms: { limit: 12 } }; const filterId = "abc"; simple.mock(storage, "getFilter").returnWith({ id: filterId + "__WRONG", filter: { wrong_filter: 1 } }); const setFilterFn = simple.mock(storage, "setFilter").callFn(filterObj => { expect(filterObj).toBeDefined(); expect(filterObj.id).toEqual(filterId); expect(filterObj.filter).toMatchObject(filter); }); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/user").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/filter`); expect(content).toMatchObject(filter); return { filter_id: filterId }; }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req.queryParams.filter).toBe(filterId); client.stop(); return { next_batch: "123" }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(setFilterFn.callCount).toBe(1); expect(dmsMock.callCount).toBe(1); client.stop(); }); it('should create a filter when there is no stored filter', async () => { const storage = new MemoryStorageProvider(); const { client, http, hsUrl } = createTestClient(storage); const dmsMock = simple.stub(); client.dms.update = dmsMock; const userId = "@testuser:example.org"; (client).userId = userId; // to prevent calls to /whoami const filter = { rooms: { limit: 12 } }; const filterId = "abc"; const getFilterFn = simple.mock(storage, "getFilter").returnWith(null); const setFilterFn = simple.mock(storage, "setFilter").callFn(filterObj => { expect(filterObj).toBeDefined(); expect(filterObj.id).toEqual(filterId); expect(filterObj.filter).toMatchObject(filter); }); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/user").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/filter`); expect(content).toMatchObject(filter); return { filter_id: filterId }; }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req.queryParams.filter).toBe(filterId); client.stop(); return { next_batch: "123" }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(getFilterFn.callCount).toBe(1); expect(setFilterFn.callCount).toBe(1); expect(dmsMock.callCount).toBe(1); client.stop(); }); it('should use the filter ID when syncing', async () => { const storage = new MemoryStorageProvider(); const { client, http } = createTestClient(storage); const dmsMock = simple.stub(); client.dms.update = dmsMock; (client).userId = "@notused:example.org"; // to prevent calls to /whoami const filter = { rooms: { limit: 12 } }; const filterId = "abc12345"; simple.mock(storage, "getFilter").returnWith({ id: filterId, filter: filter }); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.filter).toEqual(filterId); client.stop(); return { next_batch: "1234" }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(dmsMock.callCount).toBe(1); client.stop(); }); it('should make sync requests with the new token', async () => { const storage = new MemoryStorageProvider(); const { client, http } = createTestClient(storage); (client).userId = "@notused:example.org"; // to prevent calls to /whoami const dmsUpdate = simple.stub(); client.dms.update = dmsUpdate; const filter = { rooms: { limit: 12 } }; const filterId = "abc12345"; const secondToken = "second"; const waitPromise = new Promise(((resolve) => { simple.mock(storage, "getFilter").returnWith({ id: filterId, filter: filter }); const setSyncTokenFn = simple.mock(storage, "setSyncToken").callFn(newToken => { expect(newToken).toEqual(secondToken); if (setSyncTokenFn.callCount === 2) resolve(); }); })); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.since).toBeUndefined(); return { next_batch: secondToken }; }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.since).toEqual(secondToken); client.stop(); return { next_batch: secondToken }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); await waitPromise; expect(dmsUpdate.callCount).toBe(1); client.stop(); }); it('should read the sync token from the store', async () => { const storage = new MemoryStorageProvider(); const { client, http } = createTestClient(storage); (client).userId = "@notused:example.org"; // to prevent calls to /whoami const dmsUpdate = simple.stub(); client.dms.update = dmsUpdate; const filter = { rooms: { limit: 12 } }; const filterId = "abc12345"; const syncToken = "testing"; simple.mock(storage, "getFilter").returnWith({ id: filterId, filter: filter }); const getSyncTokenFn = simple.mock(storage, "getSyncToken").returnWith(syncToken); const waitPromise = new Promise(((resolve) => { simple.mock(storage, "setSyncToken").callFn(newToken => { expect(newToken).toEqual(syncToken); resolve(); }); })); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.since).toEqual(syncToken); client.stop(); return { next_batch: syncToken }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(getSyncTokenFn.callCount).toBe(1); await waitPromise; expect(dmsUpdate.callCount).toBe(1); client.stop(); }); it('should use the syncing presence variable', async () => { const storage = new MemoryStorageProvider(); const { client, http } = createTestClient(storage); (client).userId = "@notused:example.org"; // to prevent calls to /whoami const dmsUpdate = simple.stub(); client.dms.update = dmsUpdate; const filter = { rooms: { limit: 12 } }; const filterId = "abc12345"; const presence = "online"; simple.mock(storage, "getFilter").returnWith({ id: filterId, filter: filter }); // The sync handler checks which rooms it should ignore // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, { joined_rooms: [] }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.presence).toBeUndefined(); client.syncingPresence = presence; return { next_batch: "testing" }; }); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/sync").respond(200, (path, content, req) => { expect(req).toBeDefined(); expect(req.queryParams.presence).toEqual(presence); client.stop(); return { next_batch: "testing" }; }); await Promise.all([client.start(filter), http.flushAllExpected()]); expect(dmsUpdate.callCount).toBe(1); client.stop(); }); }); describe('processSync', () => { interface ProcessSyncClient { userId: string; processSync(raw: any): Promise; } it('should process non-room account data', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((ev) => { expect(ev).toMatchObject(events[0]); }); realClient.on("account_data", spy); await client.processSync({ account_data: { events: events } }); expect(spy.callCount).toBe(1); }); it('should process left rooms', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should process left rooms account data', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.account_data", spy); const roomsObj = {}; roomsObj[roomId] = { account_data: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should use the most recent leave event', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 2 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 3 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not be affected by irrelevant events during leaves', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.not_member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 1 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not process leaves detached from events', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.not_member", state_key: userId, unsigned: { age: 1 }, }, // Intentionally don't include a membership event // { // type: "m.room.member", // state_key: userId, // unsigned: {age: 1}, // content: { membership: "leave" }, // }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 1 }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid) => { // expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(0); }); it('should not get hung up on not having an age available for leaves', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should process room invites', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await client.processSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should use the most recent invite event', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.member", state_key: userId, unsigned: { age: 2 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 3 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await client.processSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not be affected by irrelevant events during invites', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.not_member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await client.processSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not process invites detached from events', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.not_member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, // Intentionally don't send a membership event // { // type: "m.room.member", // state_key: userId, // unsigned: {age: 0}, // content: {membership: "invite"}, // }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid) => { // expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await client.processSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(0); }); it('should not get hung up by not having an age available for invites', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await client.processSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should process room joins', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.join", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should process joined room account data', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.account_data", spy); const roomsObj = {}; roomsObj[roomId] = { account_data: { events: events } }; await client.processSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not duplicate room joins', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.join", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await client.processSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); await client.processSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); }); it('should not break with missing properties', async () => { const { client: realClient } = createTestClient(); const client = (realClient); client.userId = "@syncing:example.org"; await client.processSync({}); await client.processSync({ rooms: {} }); await client.processSync({ rooms: { join: {}, leave: {}, invite: {} } }); await client.processSync({ rooms: { join: { "!test": {} }, leave: { "!test": {} }, invite: { "!test": {} }, }, }); await client.processSync({ rooms: { join: { "!test": { timeline: {} } }, leave: { "!test": { timeline: {} } }, invite: { "!test": { invite_state: {} } }, }, }); await client.processSync({ rooms: { join: { "!test": { timeline: { events: [] } } }, leave: { "!test": { timeline: { events: [] } } }, invite: { "!test": { invite_state: { events: [] } } }, }, }); }); it('should process events for joined rooms', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.not_message", content: { body: "hello world 1" }, }, { type: "m.room.message", content: { body: "hello world 2" }, }, { type: "m.room.not_message", content: { body: "hello world 3" }, }, { type: "m.room.message", content: { body: "hello world 4" }, }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.message"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.message", messageSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await client.processSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(messageSpy.callCount).toBe(2); expect(eventSpy.callCount).toBe(5); }); it('should process tombstone events', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.tombstone", content: { body: "hello world 1" }, state_key: "", }, { type: "m.room.create", content: { predecessor: { room_id: "!old:example.org" } }, state_key: "", }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const archiveSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.tombstone"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.archived", archiveSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await client.processSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(archiveSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(3); }); it('should process create events with a predecessor', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.tombstone", content: { body: "hello world 1" }, state_key: "", }, { type: "m.room.create", content: { predecessor: { room_id: "!old:example.org" } }, state_key: "", }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const upgradedSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.create"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.upgraded", upgradedSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await client.processSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(upgradedSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(3); }); it('should send events through a processor', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; // TODO: Surely the membership should be in some sort of content field? const events = [ { type: "m.room.not_message", content: { body: "hello world 1" }, }, { type: "m.room.message", content: { body: "hello world 2" }, }, { type: "m.room.member", content: { membership: "invite" }, state_key: userId, }, { type: "m.room.member", content: { membership: "join" }, state_key: userId, }, { type: "m.room.member", content: { membership: "leave" }, state_key: userId, }, ]; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.member", "m.room.message", "m.room.not_message"], }; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeTruthy(); }); const leaveSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeTruthy(); }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeTruthy(); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeTruthy(); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.message", messageSpy); realClient.on("room.event", eventSpy); realClient.addPreprocessor(processor); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await client.processSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); expect(leaveSpy.callCount).toBe(1); expect(messageSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(5); }); it('should send events through the relevant processor', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; // TODO: Surely the membership should be in some sort of content field? const events = [ { type: "m.room.not_message", content: { body: "hello world 1" }, }, { type: "m.room.message", content: { body: "hello world 2" }, }, { type: "m.room.member", content: { membership: "invite" }, state_key: userId, }, { type: "m.room.member", content: { membership: "join" }, state_key: userId, }, { type: "m.room.member", content: { membership: "leave" }, state_key: userId, }, ]; const processedA = "A"; const processedB = "B"; const processorA = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = processedA; }, getSupportedEventTypes: () => ["m.room.message"], }; const processorB = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = processedB; }, getSupportedEventTypes: () => ["m.room.not_message"], }; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeUndefined(); }); const leaveSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toBeUndefined(); }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev['processed']).toEqual(processedA); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); if (ev['type'] === 'm.room.not_message') { expect(ev['processed']).toEqual(processedB); } }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.message", messageSpy); realClient.on("room.event", eventSpy); realClient.addPreprocessor(processorA); realClient.addPreprocessor(processorB); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await client.processSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); expect(leaveSpy.callCount).toBe(1); expect(messageSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(5); }); it('should process crypto if enabled', () => testCryptoStores(async (cryptoStoreType) => { const { client: realClient } = createTestClient(null, "@alice:example.org", cryptoStoreType); const client = (realClient); const sync = { to_device: { events: [{ type: "org.example", content: { hello: "world" } }] }, device_unused_fallback_key_types: [OTKAlgorithm.Signed], device_one_time_keys_count: { [OTKAlgorithm.Signed]: 12, [OTKAlgorithm.Unsigned]: 14, }, device_lists: { changed: ["@bob:example.org"], left: ["@charlie:example.org"], }, }; const spy = simple.stub().callFn((inbox, counts, unusedFallbacks, changed, left) => { expect({ to_device: { events: inbox }, device_one_time_keys_count: counts, device_unused_fallback_key_types: unusedFallbacks, device_lists: { changed, left }, }).toMatchObject(sync); return Promise.resolve(); }); realClient.crypto.updateSyncData = spy; await client.processSync(sync); expect(spy.callCount).toBe(1); })); it('should process to-device messages regardless of crypto', async () => { const { client: realClient } = createTestClient(); const client = (realClient); const sync = { to_device: { events: [{ type: "org.example", content: { hello: "world" } }] }, }; const spy = simple.stub().callFn((toDeviceMsg: IToDeviceMessage) => { expect(toDeviceMsg).toMatchObject(sync.to_device.events[0]); }); realClient.on("to-device", spy); await client.processSync(sync); expect(spy.callCount).toBe(1); }); }); describe('getEvent', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.message" }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); }); it('should process events', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.message" }; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.message"], }; client.addPreprocessor(processor); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(result["processed"]).toBeTruthy(); }); it('should try decryption', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.encrypted", content: { encrypted: true } }; const decrypted = { type: "m.room.message", content: { hello: "world" } }; const isEncSpy = simple.stub().callFn(async (rid) => { expect(rid).toEqual(roomId); return true; }); client.crypto.isRoomEncrypted = isEncSpy; const decryptSpy = simple.stub().callFn(async (ev, rid) => { expect(ev.raw).toMatchObject(event); expect(rid).toEqual(roomId); return new RoomEvent(decrypted); }); client.crypto.decryptRoomEvent = decryptSpy; const processSpy = simple.stub().callFn(async (ev) => { if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { expect(ev).toMatchObject(event); } else { expect(ev).toMatchObject(decrypted); } return ev; }); (client).processEvent = processSpy; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(decrypted); expect(processSpy.callCount).toBe(2); expect(isEncSpy.callCount).toBe(1); expect(decryptSpy.callCount).toBe(1); })); it('should not try decryption in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.encrypted", content: { encrypted: true } }; const decrypted = { type: "m.room.message", content: { hello: "world" } }; const isEncSpy = simple.stub().callFn(async (rid) => { expect(rid).toEqual(roomId); return false; }); client.crypto.isRoomEncrypted = isEncSpy; const decryptSpy = simple.stub().callFn(async (ev, rid) => { expect(ev.raw).toMatchObject(event); expect(rid).toEqual(roomId); return new RoomEvent(decrypted); }); client.crypto.decryptRoomEvent = decryptSpy; const processSpy = simple.stub().callFn(async (ev) => { if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { expect(ev).toMatchObject(event); } else { expect(ev).toMatchObject(decrypted); } return ev; }); (client).processEvent = processSpy; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(processSpy.callCount).toBe(1); expect(isEncSpy.callCount).toBe(1); expect(decryptSpy.callCount).toBe(0); })); }); describe('getRawEvent', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.message" }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getRawEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); }); it('should process events', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.message" }; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.message"], }; client.addPreprocessor(processor); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getRawEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(result["processed"]).toBeTruthy(); }); it('should not try decryption in any rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!abc123:example.org"; const eventId = "$example:example.org"; const event = { type: "m.room.encrypted", content: { encrypted: true } }; const decrypted = { type: "m.room.message", content: { hello: "world" } }; const isEncSpy = simple.stub().callFn(async (rid) => { expect(rid).toEqual(roomId); return false; }); client.crypto.isRoomEncrypted = isEncSpy; const decryptSpy = simple.stub().callFn(async (ev, rid) => { expect(ev.raw).toMatchObject(event); expect(rid).toEqual(roomId); return new RoomEvent(decrypted); }); client.crypto.decryptRoomEvent = decryptSpy; const processSpy = simple.stub().callFn(async (ev) => { if (ev['type'] === 'm.room.encrypted' && (processSpy.callCount % 2 !== 0)) { expect(ev).toMatchObject(event); } else { expect(ev).toMatchObject(decrypted); } return ev; }); (client).processEvent = processSpy; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`); return event; }); const [result] = await Promise.all([client.getRawEvent(roomId, eventId), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(processSpy.callCount).toBe(1); expect(isEncSpy.callCount).toBe(0); expect(decryptSpy.callCount).toBe(0); })); }); describe('getRoomState', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const events = [{ type: "m.room.message" }, { type: "m.room.not_message" }]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`); return events; }); const [result] = await Promise.all([client.getRoomState(roomId), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(events.length); for (let i = 0; i < result.length; i++) { expect(result[i]).toMatchObject(events[i]); } }); it('should process events', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const events = [{ type: "m.room.message" }, { type: "m.room.not_message" }]; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.message"], }; client.addPreprocessor(processor); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`); return events; }); const [result] = await Promise.all([client.getRoomState(roomId), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(events.length); for (let i = 0; i < result.length; i++) { expect(result[i]).toMatchObject(events[i]); if (result[i]['type'] === 'm.room.message') { expect(result[i]['processed']).toBeTruthy(); } else { expect(result[i]['processed']).toBeUndefined(); } } }); }); describe('getRoomStateEvent', () => { it('should call the right endpoint with no state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventType = "m.room.message"; const event = { type: "m.room.message" }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/`); return event; }); const [result] = await Promise.all([client.getRoomStateEvent(roomId, eventType, ""), http.flushAllExpected()]); expect(result).toMatchObject(event); }); it('should call the right endpoint with a state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventType = "m.room.message"; const event = { type: "m.room.message" }; const stateKey = "testing"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${stateKey}`); return event; }); const [result] = await Promise.all([client.getRoomStateEvent(roomId, eventType, stateKey), http.flushAllExpected()]); expect(result).toMatchObject(event); }); it('should process events with no state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventType = "m.room.message"; const event = { type: "m.room.message" }; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.message"], }; client.addPreprocessor(processor); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/`); return event; }); const [result] = await Promise.all([client.getRoomStateEvent(roomId, eventType, ""), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(result["processed"]).toBeTruthy(); }); it('should process events with a state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!abc123:example.org"; const eventType = "m.room.message"; const event = { type: "m.room.message" }; const stateKey = "testing"; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.message"], }; client.addPreprocessor(processor); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${stateKey}`); return event; }); const [result] = await Promise.all([client.getRoomStateEvent(roomId, eventType, stateKey), http.flushAllExpected()]); expect(result).toMatchObject(event); expect(result["processed"]).toBeTruthy(); }); }); describe('getEventContext', () => { it('should use the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const targetEvent = { eventId: "$test:example.org", type: "m.room.message", content: { body: "test", msgtype: "m.text" }, }; const before = [{ type: "m.room.message", content: { body: "1", msgtype: "m.text" } }, { type: "m.room.message", content: { body: "2", msgtype: "m.text" }, }]; const after = [{ type: "m.room.message", content: { body: "3", msgtype: "m.text" } }, { type: "m.room.message", content: { body: "4", msgtype: "m.text" }, }]; const state = [{ type: "m.room.member", state_key: "@alice:example.org", content: { body: "3", msgtype: "m.text" }, }, { type: "m.room.member", state_key: "@alice:example.org", content: { body: "4", msgtype: "m.text" } }]; const roomId = "!abc123:example.org"; const limit = 2; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(targetEvent.eventId)}`); expect(req.queryParams['limit']).toEqual(limit); return { event: targetEvent, events_before: before, events_after: after, state: state, }; }); const [result] = await Promise.all([client.getEventContext(roomId, targetEvent.eventId, limit), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.event).toBeDefined(); expect(result.event.raw).toMatchObject(targetEvent); expect(result.before).toBeDefined(); expect(result.before.length).toBe(2); expect(result.before[0]).toBeDefined(); expect(result.before[0].raw).toMatchObject(before[0]); expect(result.before[1]).toBeDefined(); expect(result.before[1].raw).toMatchObject(before[1]); expect(result.after).toBeDefined(); expect(result.after.length).toBe(2); expect(result.after[0]).toBeDefined(); expect(result.after[0].raw).toMatchObject(after[0]); expect(result.after[1]).toBeDefined(); expect(result.after[1].raw).toMatchObject(after[1]); expect(result.state).toBeDefined(); expect(result.state.length).toBe(2); expect(result.state[0]).toBeDefined(); expect(result.state[0].raw).toMatchObject(state[0]); expect(result.state[1]).toBeDefined(); expect(result.state[1].raw).toMatchObject(state[1]); }); }); describe('getUserProfile', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@testing:example.org"; const profile = { displayname: "testing", avatar_url: "testing", extra: "testing" }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/profile").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}`); return profile; }); const [result] = await Promise.all([client.getUserProfile(userId), http.flushAllExpected()]); expect(result).toMatchObject(profile); }); }); describe('createRoom', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const roomId = "!something:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/createRoom").respond(200, (path, content) => { expect(content).toMatchObject({}); return { room_id: roomId }; }); const [result] = await Promise.all([client.createRoom(), http.flushAllExpected()]); expect(result).toEqual(roomId); }); it('should call the right endpoint with all the provided properties', async () => { const { client, http } = createTestClient(); const roomId = "!something:example.org"; const properties: RoomCreateOptions = { name: "hello world", preset: "public_chat", }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/createRoom").respond(200, (path, content) => { expect(content).toMatchObject(properties); return { room_id: roomId }; }); const [result] = await Promise.all([client.createRoom(properties), http.flushAllExpected()]); expect(result).toEqual(roomId); }); }); describe('setDisplayName', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@testing:example.org"; const displayName = "Hello World"; (client).userId = userId; // avoid /whoami lookup // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/displayname`); expect(content).toMatchObject({ displayname: displayName }); return {}; }); await Promise.all([client.setDisplayName(displayName), http.flushAllExpected()]); }); }); describe('setAvatarUrl', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const userId = "@testing:example.org"; const displayName = "Hello World"; (client).userId = userId; // avoid /whoami lookup // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/avatar_url`); expect(content).toMatchObject({ avatar_url: displayName }); return {}; }); await Promise.all([client.setAvatarUrl(displayName), http.flushAllExpected()]); }); }); describe('joinRoom', () => { it('should call the right endpoint for room IDs', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; (client).userId = "@joins:example.org"; // avoid /whoami lookup // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/join").respond(200, (path, content) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`); return { room_id: roomId }; }); const [result] = await Promise.all([client.joinRoom(roomId), http.flushAllExpected()]); expect(result).toEqual(roomId); }); it('should call the right endpoint with server names', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const serverNames = ['example.org', 'localhost']; (client).userId = "@joins:example.org"; // avoid /whoami lookup // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/join").respond(200, (path, content, req) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`); expect(req.queryParams['server_name'].length).toEqual(serverNames.length); for (let i = 0; i < serverNames.length; i++) { expect(req.queryParams['server_name'][i]).toEqual(serverNames[i]); } return { room_id: roomId }; }); const [result] = await Promise.all([client.joinRoom(roomId, serverNames), http.flushAllExpected()]); expect(result).toEqual(roomId); }); it('should call the right endpoint for room aliases', async () => { const { client, http, hsUrl } = createTestClient(); const roomAlias = "#abc123:example.org"; const roomId = "!testing:example.org"; (client).userId = "@joins:example.org"; // avoid /whoami lookup // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/join").respond(200, (path, content) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/join/${encodeURIComponent(roomAlias)}`); return { room_id: roomId }; }); const [result] = await Promise.all([client.joinRoom(roomAlias), http.flushAllExpected()]); expect(result).toEqual(roomId); }); it('should use a join strategy for room IDs', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@joins:example.org"; const strategy = { joinRoom: (rid: string, uid: string, apiCall: any) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); return apiCall(roomId); }, }; (client).userId = userId; // avoid /whoami lookup client.setJoinStrategy(strategy); const strategySpy = simple.mock(strategy, "joinRoom").callOriginal(); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/join").respond(200, (path, content) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`); return { room_id: roomId }; }); const [result] = await Promise.all([client.joinRoom(roomId), http.flushAllExpected()]); expect(result).toEqual(roomId); expect(strategySpy.callCount).toBe(1); }); it('should use a join strategy for room aliases', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const roomAlias = "#abc123:example.org"; const userId = "@joins:example.org"; const strategy = { joinRoom: (rid: string, uid: string, apiCall: any) => { expect(rid).toEqual(roomAlias); expect(uid).toEqual(userId); return apiCall(roomId); }, }; (client).userId = userId; // avoid /whoami lookup client.setJoinStrategy(strategy); const strategySpy = simple.mock(strategy, "joinRoom").callOriginal(); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/join").respond(200, (path, content) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`); return { room_id: roomId }; }); const [result] = await Promise.all([client.joinRoom(roomAlias), http.flushAllExpected()]); expect(result).toEqual(roomId); expect(strategySpy.callCount).toBe(1); }); }); describe('getJoinedRooms', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomIds = ["!abc:example.org", "!123:example.org"]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/joined_rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/joined_rooms`); return { joined_rooms: roomIds }; }); const [result] = await Promise.all([client.getJoinedRooms(), http.flushAllExpected()]); expectArrayEquals(roomIds, result); }); }); describe('getJoinedRoomMembers', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const members = ["@alice:example.org", "@bob:example.org"]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`); const obj = {}; for (const member of members) obj[member] = { membership: "join" }; return { joined: obj }; }); const [result] = await Promise.all([client.getJoinedRoomMembers(roomId), http.flushAllExpected()]); expectArrayEquals(members, result); }); }); describe('getJoinedRoomMembersWithProfiles', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const members = { "@alice:example.org": { display_name: "Alice of Wonderland", }, "@bob:example.org": { display_name: "Bob the Builder", avatar_url: "mxc://foo/bar", }, }; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, path => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`); return { joined: members }; }); const [result] = await Promise.all([client.getJoinedRoomMembersWithProfiles(roomId), http.flushAllExpected()]); expect(result).toEqual(members); }); }); describe('getRoomMembers', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "join", }, }, { type: "m.room.member", state_key: "@bob:example.org", content: { membership: "leave", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembers(roomId), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(2); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); expect(result[1].membership).toBe(memberEvents[1]['content']['membership']); expect(result[1].membershipFor).toBe(memberEvents[1]['state_key']); }); it('should call the right endpoint with a batch token', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "join", }, }, { type: "m.room.member", state_key: "@bob:example.org", content: { membership: "leave", }, }, ]; const atToken = "test_token"; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect(req.queryParams.at).toEqual(atToken); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembers(roomId, atToken), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(2); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); expect(result[1].membership).toBe(memberEvents[1]['content']['membership']); expect(result[1].membershipFor).toBe(memberEvents[1]['state_key']); }); it('should call the right endpoint with membership filtering', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "join", }, }, { type: "m.room.member", state_key: "@bob:example.org", content: { membership: "leave", }, }, ]; const forMemberships: Membership[] = ['join', 'leave']; const forNotMemberships: Membership[] = ['ban']; http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toEqual("join"); expect((req.queryParams as any).not_membership).toBeFalsy(); return { chunk: [memberEvents[0]] }; }); http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toEqual("leave"); expect((req.queryParams as any).not_membership).toBeFalsy(); return { chunk: [memberEvents[1]] }; }); http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toBeFalsy(); expect((req.queryParams as any).not_membership).toEqual("ban"); return { chunk: [] }; }); const [result] = await Promise.all([client.getRoomMembers(roomId, null, forMemberships, forNotMemberships), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(2); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); expect(result[1].membership).toBe(memberEvents[1]['content']['membership']); expect(result[1].membershipFor).toBe(memberEvents[1]['state_key']); }); }); describe('getRoomMembersByMembership', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "join", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toEqual("join"); expect((req.queryParams as any).not_membership).toBeFalsy(); expect((req.queryParams as any).at).toBeFalsy(); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembersByMembership(roomId, "join"), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); }); it('should call the right endpoint with a batch token', async () => { const { client, http, hsUrl } = createTestClient(); const batchToken = "tokenhere"; const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "join", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toEqual("join"); expect((req.queryParams as any).not_membership).toBeFalsy(); expect((req.queryParams as any).at).toEqual(batchToken); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembersByMembership(roomId, "join", batchToken), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); }); }); describe('getRoomMembersWithoutMembership', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "leave", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toBeFalsy(); expect((req.queryParams as any).not_membership).toEqual("join"); expect((req.queryParams as any).at).toBeFalsy(); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembersWithoutMembership(roomId, "join"), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); }); it('should call the right endpoint with a batch token', async () => { const { client, http, hsUrl } = createTestClient(); const batchToken = "tokenhere"; const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "leave", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toBeFalsy(); expect((req.queryParams as any).not_membership).toEqual("join"); expect((req.queryParams as any).at).toEqual(batchToken); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getRoomMembersWithoutMembership(roomId, "join", batchToken), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); }); }); describe('getAllRoomMembers', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "leave", }, }, { type: "m.room.member", state_key: "@bob:example.org", content: { membership: "join", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toBeFalsy(); expect((req.queryParams as any).not_membership).toBeFalsy(); expect((req.queryParams as any).at).toBeFalsy(); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getAllRoomMembers(roomId), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(2); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); expect(result[1].membership).toBe(memberEvents[1]['content']['membership']); expect(result[1].membershipFor).toBe(memberEvents[1]['state_key']); }); it('should call the right endpoint with a batch token', async () => { const { client, http, hsUrl } = createTestClient(); const batchToken = "tokenhere"; const roomId = "!testing:example.org"; const memberEvents = [ // HACK: These are minimal events for testing purposes only. { type: "m.room.member", state_key: "@alice:example.org", content: { membership: "leave", }, }, { type: "m.room.member", state_key: "@bob:example.org", content: { membership: "join", }, }, ]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/members`); expect((req.queryParams as any).membership).toBeFalsy(); expect((req.queryParams as any).not_membership).toBeFalsy(); expect((req.queryParams as any).at).toEqual(batchToken); return { chunk: memberEvents }; }); const [result] = await Promise.all([client.getAllRoomMembers(roomId, batchToken), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.length).toBe(2); expect(result[0].membership).toBe(memberEvents[0]['content']['membership']); expect(result[0].membershipFor).toBe(memberEvents[0]['state_key']); expect(result[1].membership).toBe(memberEvents[1]['content']['membership']); expect(result[1].membershipFor).toBe(memberEvents[1]['state_key']); }); }); describe('leaveRoom', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(content).toEqual({}); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`); return {}; }); await Promise.all([client.leaveRoom(roomId), http.flushAllExpected()]); }); it('should include a reason if provided', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const reason = "I am done testing here"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(content).toEqual({ reason }); expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`); return {}; }); await Promise.all([client.leaveRoom(roomId, reason), http.flushAllExpected()]); }); }); describe('forgetRoom', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/forget`); return {}; }); await Promise.all([client.forgetRoom(roomId), http.flushAllExpected()]); }); }); describe('sendReadReceipt', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/rooms").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`); return {}; }); await Promise.all([client.sendReadReceipt(roomId, eventId), http.flushAllExpected()]); }); }); describe('setTyping', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@test:example.com"; const typing = true; const timeout = 15000; // ms client.getUserId = () => Promise.resolve(userId); // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(userId)}`); expect(content).toMatchObject({ typing: typing, timeout: timeout }); return {}; }); await Promise.all([client.setTyping(roomId, typing, timeout), http.flushAllExpected()]); }); }); describe('replyText', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyText(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; const expectedContent = { encrypted: true, }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(expectedPlainContent); return expectedContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyText(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyText(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should use encoded plain text as the HTML component', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyText(roomId, originalEvent, replyText), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); describe('replyHtmlText', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlText(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; const expectedContent = { encrypted: true, }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(expectedPlainContent); return expectedContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlText(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.text", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlText(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('replyNotice', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyNotice(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; const expectedContent = { encrypted: true, }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(expectedPlainContent); return expectedContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyNotice(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyNotice(roomId, originalEvent, replyText, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should use encoded plain text as the HTML component', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = ""; const replyHtml = "<testing1234>"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyNotice(roomId, originalEvent, replyText), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); describe('replyHtmlNotice', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlNotice(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedPlainContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; const expectedContent = { encrypted: true, }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(expectedPlainContent); return expectedContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlNotice(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const originalEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const replyText = "HELLO WORLD"; // expected const replyHtml = "

Hello World

"; const expectedContent = { "m.relates_to": { "m.in_reply_to": { "event_id": originalEvent.event_id, }, }, "msgtype": "m.notice", "format": "org.matrix.custom.html", "body": `> <${originalEvent.sender}> ${originalEvent.content.body}\n\n${replyText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalEvent.sender}
${originalEvent.content.formatted_body}
${replyHtml}`, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.replyHtmlNotice(roomId, originalEvent, replyHtml), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendNotice', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.notice", }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendNotice(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventPlainContent = { body: "Hello World", msgtype: "m.notice", }; const eventContent = { encrypted: true, body: "Hello World", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendNotice(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.notice", }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendNotice(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendHtmlNotice', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "HELLO WORLD", msgtype: "m.notice", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlNotice(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventPlainContent = { body: "HELLO WORLD", msgtype: "m.notice", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; const eventContent = { encrypted: true, formatted_body: "

Hello World

", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlNotice(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "HELLO WORLD", msgtype: "m.notice", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlNotice(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendText', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.text", }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendText(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventPlainContent = { body: "Hello World", msgtype: "m.text", }; const eventContent = { encrypted: true, body: "Hello World", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendText(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.text", }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendText(roomId, eventContent.body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendHtmlText', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "HELLO WORLD", msgtype: "m.text", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlText(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventPlainContent = { body: "HELLO WORLD", msgtype: "m.text", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; const eventContent = { encrypted: true, formatted_body: "

Hello World

", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlText(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "HELLO WORLD", msgtype: "m.text", format: "org.matrix.custom.html", formatted_body: "

Hello World

", }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendHtmlText(roomId, eventContent.formatted_body), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendMessage', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.text", sample: true, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendMessage(roomId, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventPlainContent = { body: "Hello World", msgtype: "m.text", sample: true, }; const eventContent = { encrypted: true, body: "Hello World", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual("m.room.message"); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendMessage(roomId, eventPlainContent), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventContent = { body: "Hello World", msgtype: "m.text", sample: true, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendMessage(roomId, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendEvent', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const eventContent = { testing: "hello world", sample: true, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendEvent(roomId, eventType, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should try to encrypt in encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const sEventType = "m.room.encrypted"; const eventPlainContent = { testing: "hello world", sample: true, }; const eventContent = { encrypted: true, body: "Hello World", }; client.crypto.isRoomEncrypted = async () => true; // for this test client.crypto.encryptRoomEvent = async (rid, t, c) => { expect(rid).toEqual(roomId); expect(t).toEqual(eventType); expect(c).toMatchObject(eventPlainContent); return eventContent as any; }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(sEventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendEvent(roomId, eventType, eventPlainContent), http.flushAllExpected()]); expect(result).toEqual(eventId); })); it('should not try to encrypt in unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const eventContent = { testing: "hello world", sample: true, }; client.crypto.isRoomEncrypted = async () => false; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendEvent(roomId, eventType, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendRawEvent', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const eventContent = { testing: "hello world", sample: true, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendEvent(roomId, eventType, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should not try to encrypt in any rooms', () => testCryptoStores(async (cryptoStoreType) => { const { client, http, hsUrl } = createTestClient(null, "@alice:example.org", cryptoStoreType); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const eventContent = { testing: "hello world", sample: true, }; client.crypto.isRoomEncrypted = async () => true; // for this test // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendRawEvent(roomId, eventType, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); })); }); describe('sendStateEvent', () => { it('should call the right endpoint with no state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const stateKey = ""; const eventType = "m.room.message"; const eventContent = { body: "Hello World", msgtype: "m.text", sample: true, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendStateEvent(roomId, eventType, stateKey, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); }); it('should call the right endpoint with a state key', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const stateKey = "testing"; const eventType = "m.room.message"; const eventContent = { body: "Hello World", msgtype: "m.text", sample: true, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendStateEvent(roomId, eventType, stateKey, eventContent), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); describe('redactEvent', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const reason = "Zvarri!"; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/`); expect(idx).toBe(0); expect(content).toMatchObject({ reason }); return { event_id: eventId }; }); const [result] = await Promise.all([client.redactEvent(roomId, eventId, reason), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); describe('setUserPowerLevel', () => { it('should use the current power levels as a base', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const targetLevel = 65; const basePowerLevels = { ban: 100, users: { "@alice:example.org": 100, }, }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return basePowerLevels; }); const sendStateEventSpy = simple.mock(client, "sendStateEvent").callFn((rid, evType, stateKey, content) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); expect(content).toMatchObject(Object.assign({}, { users: { [userId]: targetLevel } }, basePowerLevels)); return null; }); await client.setUserPowerLevel(userId, roomId, targetLevel); expect(getStateEventSpy.callCount).toBe(1); expect(sendStateEventSpy.callCount).toBe(1); }); it('should fill in the users object if not present on the original state event', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const targetLevel = 65; const basePowerLevels = { ban: 100, }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return basePowerLevels; }); const sendStateEventSpy = simple.mock(client, "sendStateEvent").callFn((rid, evType, stateKey, content) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); expect(content).toMatchObject(Object.assign({}, { users: { [userId]: targetLevel } }, basePowerLevels)); return null; }); await client.setUserPowerLevel(userId, roomId, targetLevel); expect(getStateEventSpy.callCount).toBe(1); expect(sendStateEventSpy.callCount).toBe(1); }); }); describe('userHasPowerLevelFor', () => { it('throws when a power level event cannot be located', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return null; }); try { await client.userHasPowerLevelFor(userId, roomId, eventType, isState); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected call to fail"); } catch (e) { expect(e.message).toEqual("No power level event found"); } expect(getStateEventSpy.callCount).toBe(1); }); it('assumes PL50 for state events when no power level information is available', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = true; const plEvent = { users: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 50; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 49; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 51; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(3); }); it('assumes PL0 for non-state events when no power level information is available', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { users: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 0; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = -1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); plEvent.users = {}; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(4); }); it('uses the state_default parameter', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = true; const plEvent = { state_default: 75, events_default: 99, users: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 75; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 74; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); }); it('uses the events_default parameter', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { state_default: 99, events_default: 75, users: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 75; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 74; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); }); it('uses the users_default parameter', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { events_default: 75, users_default: 15 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(1); plEvent.users_default = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); }); it('uses the events[event_type] parameter for non-state events', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { state_default: 99, events_default: 99, events: {}, users: {} }; plEvent["events"][eventType] = 75; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 75; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 74; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); }); it('uses the events[event_type] parameter for state events', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = true; const plEvent = { state_default: 99, events_default: 99, events: {}, users: {} }; plEvent["events"][eventType] = 75; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 75; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 74; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); }); it('uses the events[event_type] parameter safely', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { state_default: 99, events_default: 75, events: {}, users: {} }; plEvent["events"][eventType + "_wrong"] = 99; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.users[userId] = 75; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.users[userId] = 76; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); plEvent.users[userId] = 74; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(3); }); it('defaults the user to PL0', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { events: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.events[eventType] = 0; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.events[eventType] = 1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(2); plEvent.events[eventType] = -1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(3); }); it('defaults the user to PL0 safely', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { events: {}, users: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent.events[eventType] = 0; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent.events[eventType] = 1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(2); plEvent.events[eventType] = -1; result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(3); }); it('rejects string power levels', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const plEvent = { events: { [eventType]: "10" }, users_default: 0 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const result = await client.userHasPowerLevelFor(userId, roomId, eventType, isState); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); }); }); describe('userHasPowerLevelFor', () => { it('throws when a power level event cannot be located', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const eventType = "m.room.message"; const isState = false; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return null; }); try { await client.userHasPowerLevelFor(userId, roomId, eventType, isState); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected call to fail"); } catch (e) { expect(e.message).toEqual("No power level event found"); } expect(getStateEventSpy.callCount).toBe(1); }); // Doubles as a test to ensure the right action is used it('uses the users_default parameter', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.Ban; const plEvent = { [action]: 75, users_default: 15 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(1); plEvent.users_default = 76; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); }); it('should work with @room notifications', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.NotifyRoom; const plEvent = { notifications: { room: 75 }, users_default: 15 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(1); plEvent.users_default = 76; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); }); it('should work with @room notifications when `notifications` is missing', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.NotifyRoom; const plEvent = { users_default: 15 }; // deliberately left out action level const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(1); }); it('defaults the user to PL0', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.Ban; const plEvent = { events: {} }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent[action] = 0; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent[action] = 1; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(2); plEvent[action] = -1; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(3); }); it('defaults the user to PL0 safely', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.Ban; const plEvent = { events: {}, users: {}, [action]: 50 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; plEvent[action] = 0; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(1); plEvent[action] = 1; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(2); plEvent[action] = -1; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(3); }); it('rejects string power levels', async () => { const { client } = createTestClient(); const roomId = "!testing:example.org"; const userId = "@testing:example.org"; const action = PowerLevelAction.Ban; const plEvent = { [action]: "40", users_default: 45 }; const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); let result; result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(false); expect(getStateEventSpy.callCount).toBe(1); plEvent[action] = 40; // just to be sure (cast required to appease TS) result = await client.userHasPowerLevelForAction(userId, roomId, action); expect(result).toBe(true); expect(getStateEventSpy.callCount).toBe(2); }); }); describe('calculatePowerLevelChangeBoundsOn', () => { it('throws when a power level event cannot be located', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const userId = await client.getUserId(); const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return null; }); try { await client.calculatePowerLevelChangeBoundsOn(userId, roomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected call to fail"); } catch (e) { expect(e.message).toEqual("No power level event found"); } expect(getStateEventSpy.callCount).toBe(1); }); it('allows moderators to demote themselves', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = await client.getUserId(); const plEvent = { state_default: 50, users: { [targetUserId]: 50, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(true); expect(bounds.maximumPossibleLevel).toBe(plEvent.users[targetUserId]); }); it('allows admins to demote themselves', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = await client.getUserId(); const plEvent = { state_default: 50, users: { [targetUserId]: 100, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(true); expect(bounds.maximumPossibleLevel).toBe(plEvent.users[targetUserId]); }); it('denies moderators from promoting themselves', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = await client.getUserId(); const plEvent = { state_default: 100, users: { [targetUserId]: 50, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(false); expect(bounds.maximumPossibleLevel).toBe(0); // zero because it doesn't know }); it('prevents users from promoting above themselves', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = "@another:example.org"; const userLevel = 40; const targetLevel = 50; const plEvent = { state_default: 10, users: { [targetUserId]: targetLevel, [await client.getUserId()]: userLevel, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(false); expect(bounds.maximumPossibleLevel).toBe(userLevel); }); it('allows users to promote up to their power level', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = "@another:example.org"; const userLevel = 60; const targetLevel = 50; const plEvent = { state_default: 10, users: { [targetUserId]: targetLevel, [await client.getUserId()]: userLevel, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(true); expect(bounds.maximumPossibleLevel).toBe(userLevel); }); it('denies modification for exactly the same level', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = "@another:example.org"; const userLevel = 50; const targetLevel = 50; const plEvent = { state_default: 10, users: { [targetUserId]: targetLevel, [await client.getUserId()]: userLevel, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(false); expect(bounds.maximumPossibleLevel).toBe(userLevel); }); it('denies modification if the state event is too high of power', async () => { const { client } = createTestClient(null, '@testing:example.org'); const roomId = "!testing:example.org"; const targetUserId = "@another:example.org"; const userLevel = 50; const targetLevel = 50; const plEvent = { state_default: 1000, users: { [targetUserId]: targetLevel, [await client.getUserId()]: userLevel, }, }; simple.mock(client, "getRoomStateEvent").callFn((rid, evType, stateKey) => { expect(rid).toEqual(roomId); expect(evType).toEqual("m.room.power_levels"); expect(stateKey).toEqual(""); return plEvent; }); const bounds = await client.calculatePowerLevelChangeBoundsOn(targetUserId, roomId); expect(bounds).toBeDefined(); expect(bounds.canModify).toBe(false); expect(bounds.maximumPossibleLevel).toBe(0); // zero because it doesn't know }); }); describe('mxcToHttp', () => { it('should convert to the right URL', async () => { const { client, hsUrl } = createTestClient(); const domain = "example.org"; const mediaId = "testing/val"; const mxc = `mxc://${domain}/${mediaId}`; const http = client.mxcToHttp(mxc); expect(http).toBe(`${hsUrl}/_matrix/media/v3/download/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`); }); it('should error for non-MXC URIs', async () => { const { client } = createTestClient(); const domain = "example.org"; const mediaId = "testing/val"; const mxc = `https://${domain}/${mediaId}`; try { client.mxcToHttp(mxc); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected an error and didn't get one"); } catch (e) { expect(e.message).toEqual("Not a MXC URI"); } }); }); describe('mxcToHttpThumbnail', () => { it('should convert to the right URL', async () => { const { client, hsUrl } = createTestClient(); const domain = "example.org"; const mediaId = "testing/val"; const width = 240; const height = 600; const method = "scale"; const mxc = `mxc://${domain}/${mediaId}`; const http = client.mxcToHttpThumbnail(mxc, width, height, method); // eslint-disable-next-line max-len expect(http).toBe(`${hsUrl}/_matrix/media/v3/thumbnail/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}?width=${width}&height=${height}&method=${encodeURIComponent(method)}`); }); it('should error for non-MXC URIs', async () => { const { client } = createTestClient(); const domain = "example.org"; const mediaId = "testing/val"; const width = 240; const height = 600; const method = "scale"; const mxc = `https://${domain}/${mediaId}`; try { client.mxcToHttpThumbnail(mxc, width, height, method); // noinspection ExceptionCaughtLocallyJS throw new Error("Expected an error and didn't get one"); } catch (e) { expect(e.message).toEqual("Not a MXC URI"); } }); }); describe('uploadContent', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const data = (`{"hello":"world"}`); // we can't use a real buffer because of the mock library const contentType = "test/type"; const filename = null; const uri = "mxc://example.org/testing"; Buffer.isBuffer = (i => i === data); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/media/v3/upload").respond(200, (path, content, req) => { expect(content).toBeDefined(); expect(req.queryParams.filename).toEqual(filename); expect(req.headers["Content-Type"]).toEqual(contentType); expect(req.rawData).toEqual(data); return { content_uri: uri }; }); const [result] = await Promise.all([client.uploadContent(data, contentType, filename), http.flushAllExpected()]); expect(result).toEqual(uri); }); it('should use the right filename', async () => { const { client, http } = createTestClient(); const data = (`{"hello":"world"}`); // we can't use a real buffer because of the mock library const contentType = "test/type"; const filename = "example.jpg"; const uri = "mxc://example.org/testing"; Buffer.isBuffer = (i => i === data); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/media/v3/upload").respond(200, (path, content, req) => { expect(content).toBeDefined(); expect(req.queryParams.filename).toEqual(filename); expect(req.headers["Content-Type"]).toEqual(contentType); expect(req.rawData).toEqual(data); return { content_uri: uri }; }); const [result] = await Promise.all([client.uploadContent(data, contentType, filename), http.flushAllExpected()]); expect(result).toEqual(uri); }); }); describe('downloadContent', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const urlPart = "example.org/testing"; const mxcUrl = "mxc://" + urlPart; // const fileContents = Buffer.from("12345"); // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v1/media/download/").respond(200, (path, _, req) => { expect(path).toContain("/_matrix/client/v1/media/download/" + urlPart); expect((req as any).opts.encoding).toEqual(null); // TODO: Honestly, I have no idea how to coerce the mock library to return headers or buffers, // so this is left as a fun activity. // return { // body: fileContents, // headers: { // "content-type": "test/test", // }, // }; return {}; }); // Due to the above problem, the output of this won't be correct, so we cannot verify it. const [res] = await Promise.all([client.downloadContent(mxcUrl), http.flushAllExpected()]); expect(Object.keys(res)).toContain("data"); expect(Object.keys(res)).toContain("contentType"); }); }); describe('uploadContentFromUrl', () => { it('should download then upload the content', async () => { const { client, http, hsUrl } = createTestClient(); const data = (`{"hello":"world"}`); // we can't use a real buffer because of the mock library const uri = "mxc://example.org/testing"; Buffer.isBuffer = (i => i === data); // noinspection TypeScriptValidateJSTypes http.when("GET", "/sample/download").respond(200, () => { // We can't override headers, so don't bother return data; }); // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/media/v3/upload").respond(200, (path, content, req) => { expect(content).toBeDefined(); // HACK: We know the mock library will return JSON expect(req.headers["Content-Type"]).toEqual("application/json"); //expect(req.opts.body).toEqual(data); // XXX: We can't verify that the content was uploaded correctly return { content_uri: uri }; }); const [result] = await Promise.all([client.uploadContentFromUrl(`${hsUrl}/sample/download`), http.flushAllExpected()]); expect(result).toEqual(uri); }); }); describe('getRoomUpgradeHistory', () => { it('should calculate the room upgrade history', async () => { const { client } = createTestClient(); const roomState = { "!prev-v3:localhost": [ // no events - we'll treat this as an end stop ], "!prev-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-prev-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!prev-v3:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!prev-v1:localhost", }, }, ], "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", "predecessor": { "room_id": "!prev-v2:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$current-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v1:localhost", }, }, ], "!new-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-new-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!current:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-new-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v2:localhost", }, }, ], "!new-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-new-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!new-v1:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-new-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v3:localhost", }, }, ], "!new-v3:localhost": [ // no events - we'll treat this as an end stop ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: "$v1-prev-t:localhost" }, { roomId: "!prev-v2:localhost", version: "2", refEventId: "$v2-prev-t:localhost" }, { roomId: "!prev-v3:localhost", version: "1", refEventId: null }, ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [ { roomId: "!new-v1:localhost", version: "2", refEventId: "$v1-new-c:localhost" }, { roomId: "!new-v2:localhost", version: "2", refEventId: "$v2-new-c:localhost" }, { roomId: "!new-v3:localhost", version: "1", refEventId: null }, ], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cases with no previous rooms', async () => { const { client } = createTestClient(); const roomState = { "!prev-v1:localhost": [ // no events - we'll treat this as an end stop ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$current-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v1:localhost", }, }, ], "!new-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-new-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!current:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-new-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v2:localhost", }, }, ], "!new-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-new-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!new-v1:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-new-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v3:localhost", }, }, ], "!new-v3:localhost": [ // no events - we'll treat this as an end stop ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: null }, ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [ { roomId: "!new-v1:localhost", version: "2", refEventId: "$v1-new-c:localhost" }, { roomId: "!new-v2:localhost", version: "2", refEventId: "$v2-new-c:localhost" }, { roomId: "!new-v3:localhost", version: "1", refEventId: null }, ], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cases with no known newer rooms', async () => { const { client } = createTestClient(); const roomState = { "!prev-v3:localhost": [ // no events - we'll treat this as an end stop ], "!prev-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-prev-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!prev-v3:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!prev-v1:localhost", }, }, ], "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", "predecessor": { "room_id": "!prev-v2:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$current-t:localhost", "state_key": "", "content": { "replacement_room": "!new-v1:localhost", }, }, ], "!new-v1:localhost": [ // no events - we'll treat this as an end stop ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: "$v1-prev-t:localhost" }, { roomId: "!prev-v2:localhost", version: "2", refEventId: "$v2-prev-t:localhost" }, { roomId: "!prev-v3:localhost", version: "1", refEventId: null }, ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [ { roomId: "!new-v1:localhost", version: "1", refEventId: null }, ], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cases with no newer rooms', async () => { const { client } = createTestClient(); const roomState = { "!prev-v3:localhost": [ // no events - we'll treat this as an end stop ], "!prev-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-prev-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!prev-v3:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!prev-v1:localhost", }, }, ], "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", "predecessor": { "room_id": "!prev-v2:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: "$v1-prev-t:localhost" }, { roomId: "!prev-v2:localhost", version: "2", refEventId: "$v2-prev-t:localhost" }, { roomId: "!prev-v3:localhost", version: "1", refEventId: null }, ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cases with no upgrades', async () => { const { client } = createTestClient(); const roomState = { "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", }, }, ], }; const expected = { previous: [], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle self-referencing creation events', async () => { const { client } = createTestClient(); const roomState = { "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!current:localhost", }, }, }, ], }; const expected = { previous: [], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle self-referencing tombstones', async () => { const { client } = createTestClient(); const roomState = { "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!prev-v1:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, ], }; const expected = { previous: [{ roomId: "!prev-v1:localhost", version: "1", refEventId: null }], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cyclical upgrades through predecessors', async () => { const { client } = createTestClient(); const roomState = { "!prev-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-prev-c:localhost", "state_key": "", "content": { "room_version": "2", "predecessor": { "room_id": "!current:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v2-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!prev-v1:localhost", }, }, ], "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", "predecessor": { "room_id": "!prev-v2:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: "$v1-prev-t:localhost" }, { roomId: "!prev-v2:localhost", version: "2", refEventId: "$v2-prev-t:localhost" }, { roomId: "!current:localhost", version: "3", refEventId: null }, // indicates loop ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); it('should handle cyclical upgrades through tombstones', async () => { const { client } = createTestClient(); const roomState = { "!prev-v2:localhost": [ { "type": "m.room.create", "event_id": "$v2-prev-c:localhost", "state_key": "", "content": { "room_version": "2", }, }, { "type": "m.room.tombstone", "event_id": "$v2-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!prev-v1:localhost": [ { "type": "m.room.create", "event_id": "$v1-prev-c:localhost", "state_key": "", "content": { "room_version": "1", "predecessor": { "room_id": "!prev-v2:localhost", }, }, }, { "type": "m.room.tombstone", "event_id": "$v1-prev-t:localhost", "state_key": "", "content": { "replacement_room": "!current:localhost", }, }, ], "!current:localhost": [ { "type": "m.room.create", "event_id": "$current-c:localhost", "state_key": "", "content": { "room_version": "3", "predecessor": { "room_id": "!prev-v1:localhost", }, }, }, ], }; const expected = { previous: [ { roomId: "!prev-v1:localhost", version: "1", refEventId: "$v1-prev-t:localhost" }, { roomId: "!prev-v2:localhost", version: "2", refEventId: null }, ], current: { roomId: "!current:localhost", version: "3", refEventId: null }, newer: [], }; client.getRoomState = (rid) => { const state = roomState[rid]; if (state.length === 0) throw new Error("No state events"); return Promise.resolve(state); }; client.getRoomStateEvent = async (rid, eventType, stateKey) => { const state = await client.getRoomState(rid); const event = state.find(e => e['type'] === eventType && e['state_key'] === stateKey); if (!event) throw new Error("Event not found"); return event['content']; }; const result = await client.getRoomUpgradeHistory("!current:localhost"); expect(result).toMatchObject(expected); }); }); describe('createSpace', () => { it('should create a typed private room', async () => { const { client, http } = createTestClient(); client.getUserId = () => Promise.resolve("@alice:example.org"); const roomId = "!test:example.org"; const name = "Test Space"; const topic = "This is a topic"; const aliasLocalpart = "test-space"; const avatarUrl = "mxc://example.org/foobar"; const publicSpace = false; const invites = ['@foo:example.org', '@bar:example.org']; const expectedRequest = { name: name, topic: topic, preset: 'private_chat', room_alias_name: aliasLocalpart, invite: invites, initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: 'shared', }, }, { type: "m.room.avatar", state_key: "", content: { url: avatarUrl, }, }, ], creation_content: { type: 'm.space', }, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/createRoom").respond(200, (path, content) => { expect(content).toMatchObject(expectedRequest); return { room_id: roomId }; }); const [result] = await Promise.all([client.createSpace({ name: name, topic: topic, localpart: aliasLocalpart, avatarUrl, isPublic: publicSpace, invites, }), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.client).toEqual(client); expect(result.roomId).toEqual(roomId); }); it('should create a typed public room', async () => { const { client, http } = createTestClient(); client.getUserId = () => Promise.resolve("@alice:example.org"); const roomId = "!test:example.org"; const name = "Test Space"; const topic = "This is a topic"; const aliasLocalpart = "test-space"; const publicSpace = true; const expectedRequest = { name: name, topic: topic, preset: 'public_chat', room_alias_name: aliasLocalpart, initial_state: [ { type: "m.room.history_visibility", state_key: "", content: { history_visibility: 'world_readable', }, }, ], creation_content: { type: 'm.space', }, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/createRoom").respond(200, (path, content) => { expect(content).toMatchObject(expectedRequest); return { room_id: roomId }; }); const [result] = await Promise.all([client.createSpace({ name: name, topic: topic, localpart: aliasLocalpart, isPublic: publicSpace, }), http.flushAllExpected()]); expect(result).toBeDefined(); expect(result.client).toEqual(client); expect(result.roomId).toEqual(roomId); }); }); describe('getSpace', () => { it('should verify the room reference', async () => { const { client } = createTestClient(); const roomId = "!test:example.org"; const roomAlias = '#woot:example.org'; const resolveSpy = simple.spy(async (idOrAlias) => { expect(idOrAlias).toEqual(roomAlias); return roomId; }); client.resolveRoom = resolveSpy; const stateSpy = simple.spy(async (sRoomId, type, stateKey) => { expect(sRoomId).toEqual(roomId); expect(type).toEqual("m.room.create"); expect(stateKey).toEqual(""); return { type: 'm.space', }; }); client.getRoomStateEvent = stateSpy; const result = await client.getSpace(roomAlias); expect(resolveSpy.callCount).toBe(1); expect(stateSpy.callCount).toBe(1); expect(result).toBeDefined(); expect(result.client).toEqual(client); // XXX: Private member access expect(result.roomId).toEqual(roomId); }); it('should throw if the type is wrong', async () => { const { client } = createTestClient(); const roomId = "!test:example.org"; const resolveSpy = simple.spy(async (idOrAlias) => { expect(idOrAlias).toEqual(roomId); return idOrAlias; }); client.resolveRoom = resolveSpy; const stateSpy = simple.spy(async (sRoomId, type, stateKey) => { expect(sRoomId).toEqual(roomId); expect(type).toEqual("m.room.create"); expect(stateKey).toEqual(""); return { 'type': 'fibble', }; }); client.getRoomStateEvent = stateSpy; try { await client.getSpace(roomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(resolveSpy.callCount).toBe(1); expect(stateSpy.callCount).toBe(1); expect(e.message).toEqual("Room is not a space"); } }); }); describe('uploadDeviceOneTimeKeys', () => { it('should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.uploadDeviceOneTimeKeys({}); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption is not enabled"); } }); it('should call the right endpoint', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); // @ts-ignore const keys: OTKs = { [`${OTKAlgorithm.Signed}:AAAAA`]: { key: "test", signatures: { "entity": { "device": "sig", }, }, }, [`${OTKAlgorithm.Unsigned}:AAAAA`]: "unsigned", }; const counts: OTKCounts = { [OTKAlgorithm.Signed]: 12, [OTKAlgorithm.Unsigned]: 14, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/upload").respond(200, (path, content) => { expect(content).toMatchObject({ one_time_keys: keys, }); return { one_time_key_counts: counts }; }); const [result] = await Promise.all([client.uploadDeviceOneTimeKeys(keys), http.flushAllExpected()]); expect(result).toMatchObject(counts); })); }); describe('checkOneTimeKeyCounts', () => { it('should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.checkOneTimeKeyCounts(); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption is not enabled"); } }); it('should call the right endpoint', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); const counts: OTKCounts = { [OTKAlgorithm.Signed]: 12, [OTKAlgorithm.Unsigned]: 14, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/upload").respond(200, (path, content) => { expect(content).toMatchObject({}); return { one_time_key_counts: counts }; }); const [result] = await Promise.all([client.checkOneTimeKeyCounts(), http.flushAllExpected()]); expect(result).toMatchObject(counts); })); }); describe('getUserDevices', () => { it('should call the right endpoint', async () => { const { client, http } = createTestClient(); const timeout = 15000; const requestBody = { "@alice:example.org": [], "@bob:federated.example.org": [], }; const response = { failures: { "federated.example.org": { error: "Failed", }, }, device_keys: { "@alice:example.org": { [TEST_DEVICE_ID]: { // not populated in this test }, }, }, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/query").respond(200, (path, content) => { expect(content).toMatchObject({ timeout, device_keys: requestBody }); return response; }); const [result] = await Promise.all([client.getUserDevices(Object.keys(requestBody), timeout), http.flushAllExpected()]); expect(result).toMatchObject(response); }); it('should call the right endpoint with a default timeout', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); const requestBody = { "@alice:example.org": [], "@bob:federated.example.org": [], }; const response = { failures: { "federated.example.org": { error: "Failed", }, }, device_keys: { "@alice:example.org": { [TEST_DEVICE_ID]: { // not populated in this test }, }, }, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/query").respond(200, (path, content) => { expect(content).toMatchObject({ timeout: 10000, device_keys: requestBody }); return response; }); const [result] = await Promise.all([client.getUserDevices(Object.keys(requestBody)), http.flushAllExpected()]); expect(result).toMatchObject(response); })); }); describe('claimOneTimeKeys', () => { it('should fail when no encryption is available', async () => { try { const { client } = createTestClient(); await client.claimOneTimeKeys({}); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption is not enabled"); } }); it('should call the right endpoint', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); const request = { "@alice:example.org": { [TEST_DEVICE_ID]: OTKAlgorithm.Signed, }, "@bob:federated.example.org": { [TEST_DEVICE_ID + "_2ND"]: OTKAlgorithm.Unsigned, }, }; const response = { failures: { "federated.example.org": { error: "Failed", }, }, one_time_keys: { "@alice:example.org": { [TEST_DEVICE_ID]: { // not populated in this test }, }, }, }; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/claim").respond(200, (path, content) => { expect(content).toMatchObject({ timeout: 10000, one_time_keys: request, }); return response; }); const [result] = await Promise.all([client.claimOneTimeKeys(request), http.flushAllExpected()]); expect(result).toMatchObject(response); })); it('should use the timeout parameter', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); const request = { "@alice:example.org": { [TEST_DEVICE_ID]: OTKAlgorithm.Signed, }, "@bob:federated.example.org": { [TEST_DEVICE_ID + "_2ND"]: OTKAlgorithm.Unsigned, }, }; const response = { failures: { "federated.example.org": { error: "Failed", }, }, one_time_keys: { "@alice:example.org": { [TEST_DEVICE_ID]: { // not populated in this test }, }, }, }; const timeout = 60; // noinspection TypeScriptValidateJSTypes http.when("POST", "/_matrix/client/v3/keys/claim").respond(200, (path, content) => { expect(content).toMatchObject({ timeout: timeout, one_time_keys: request, }); return response; }); const [result] = await Promise.all([client.claimOneTimeKeys(request, timeout), http.flushAllExpected()]); expect(result).toMatchObject(response); })); }); describe('sendToDevices', () => { it('should call the right endpoint', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http, hsUrl } = createTestClient(null, userId, cryptoStoreType); const type = "org.example.message"; const messages = { [userId]: { "*": { isContent: true, }, }, "@alice:example.org": { [TEST_DEVICE_ID]: { moreContent: true, }, }, }; // noinspection TypeScriptValidateJSTypes http.when("PUT", "/_matrix/client/v3/sendToDevice").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/sendToDevice/${encodeURIComponent(type)}/`); expect(idx).toBe(0); expect(content).toMatchObject({ messages }); return {}; }); await Promise.all([client.sendToDevices(type, messages), http.flushAllExpected()]); })); }); describe('getOwnDevices', () => { it('should call the right endpoint', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@test:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); const devices = ["schema not followed for simplicity"]; // noinspection TypeScriptValidateJSTypes http.when("GET", "/_matrix/client/v3/devices").respond(200, () => { return { devices }; }); const [res] = await Promise.all([client.getOwnDevices(), http.flushAllExpected()]); expect(res).toMatchObject(devices); })); }); describe('getRelationsForEvent', () => { test.each([ [null, null], ['org.example.relation', null], ['org.example.relation', 'org.example.event_type'], ])("should call the right endpoint for rel=%p and type=%p", async (relType, eventType) => { const { client, http, hsUrl } = createTestClient(); const roomId = "!room:example.org"; const eventId = "$event"; const response = { chunk: [ { eventContents: true }, { eventContents: true }, { eventContents: true }, ], }; http.when("GET", "/_matrix/client/v1/rooms").respond(200, (path, content) => { const relTypeComponent = relType ? `/${encodeURIComponent(relType)}` : ''; const eventTypeComponent = eventType ? `/${encodeURIComponent(eventType)}` : ''; // eslint-disable-next-line max-len const idx = path.indexOf(`${hsUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}${relTypeComponent}${eventTypeComponent}`); expect(idx).toBe(0); return response; }); const [result] = await Promise.all([client.getRelationsForEvent(roomId, eventId, relType, eventType), http.flushAllExpected()]); expect(result).toEqual(response); }); }); describe('redactObjectForLogging', () => { it('should redact multilevel objects', () => { const input = { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", }, }, "array": [ { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "REDACT ME", "password": "REDACT ME", }, }, }, ], }; const output = { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", }, }, "array": [ { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", "subobject": { "untouched_one": 1, "untouched_two": "test", "untouched_three": false, "untouched_four": null, "access_token": "", "password": "", }, }, }, ], }; const result = redactObjectForLogging(input); expect(result).toMatchObject(output); }); }); }); ================================================ FILE: test/SynapseAdminApisTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import { IStorageProvider, MatrixClient, SynapseAdminApis, SynapseRegistrationToken, SynapseRegistrationTokenOptions, SynapseRegistrationTokenUpdateOptions, SynapseRoomList, SynapseRoomProperty, SynapseUser, SynapseUserList, SynapseUserProperties, } from "../src"; import { createTestClient } from "./TestUtils"; export function createTestSynapseAdminClient( storage: IStorageProvider = null, ): { client: SynapseAdminApis; mxClient: MatrixClient; http: HttpBackend; hsUrl: string; accessToken: string; } { const result = createTestClient(storage); const mxClient = result.client; const client = new SynapseAdminApis(mxClient); delete result.client; return { ...result, client, mxClient }; } describe('SynapseAdminApis', () => { describe('isAdmin', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; const response = { admin: true }; http.when("GET", "/_synapse/admin/v1/users").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/users/${encodeURIComponent(userId)}/admin`); return response; }); const [result] = await Promise.all([client.isAdmin(userId), http.flushAllExpected()]); expect(result).toEqual(response.admin); }); it('should return false when the user is not an admin', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; const response = { admin: false }; http.when("GET", "/_synapse/admin/v1/users").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/users/${encodeURIComponent(userId)}/admin`); return response; }); const [result] = await Promise.all([client.isAdmin(userId), http.flushAllExpected()]); expect(result).toEqual(response.admin); }); }); describe('isSelfAdmin', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; const response = { admin: true }; http.when("GET", "/_matrix/client/v3/account/whoami").respond(200, (path, content) => { return { user_id: userId }; }); http.when("GET", "/_synapse/admin/v1/users").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/users/${encodeURIComponent(userId)}/admin`); return response; }); const [result] = await Promise.all([client.isSelfAdmin(), http.flushAllExpected()]); expect(result).toEqual(response.admin); }); it('should return false if the client is not an admin', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; http.when("GET", "/_matrix/client/v3/account/whoami").respond(200, (path, content) => { return { user_id: userId }; }); http.when("GET", "/_synapse/admin/v1/users").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/users/${encodeURIComponent(userId)}/admin`); return { errcode: "M_FORBIDDEN", error: "You are not a server admin" }; }); const [result] = await Promise.all([client.isSelfAdmin(), http.flushAllExpected()]); expect(result).toEqual(false); }); }); describe('getUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; const response: SynapseUser = { displayname: "foobar", threepids: [{ medium: "email", address: "foobar@example.org", }], avatar_url: "mxc://example.org/animage", admin: true, deactivated: false, }; http.when("GET", "/_synapse/admin/v2/users").respond(200, (path) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users/${encodeURIComponent(userId)}`); return response; }); const [result] = await Promise.all([client.getUser(userId), http.flushAllExpected()]); expect(result).toEqual(response); }); it('should throw if the user cannot be found', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; http.when("GET", "/_synapse/admin/v2/users").respond(404, (path) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users/${encodeURIComponent(userId)}`); return { error: "User not found", errcode: "M_NOT_FOUND" }; }); try { await Promise.all([client.getUser(userId), http.flushAllExpected()]); } catch (ex) { expect(ex.statusCode).toBe(404); return; } throw Error('Expected to throw'); }); }); describe('upsertUser', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const userId = "@someone:example.org"; const response: SynapseUser = { displayname: "foobar", threepids: [{ medium: "email", address: "foobar@example.org", }], avatar_url: "mxc://example.org/animage", admin: true, deactivated: false, }; const request: SynapseUserProperties = { ...response, password: "foobar", }; http.when("PUT", "/_synapse/admin/v2/users").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users/${encodeURIComponent(userId)}`); expect(content).toEqual(request); return response; }); const [result] = await Promise.all([client.upsertUser(userId, request), http.flushAllExpected()]); expect(result).toEqual(response); }); }); describe('listUsers', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const response: SynapseUserList = { users: [{ name: "@someone:example.org", displayname: "foobar", avatar_url: "mxc://example.org/animage", admin: 1, deactivated: 0, is_guest: 0, user_type: null, password_hash: "$hashbrown", }], next_token: "foo", total: 1, }; const request = { from: "foo", limit: 5, name: "bar", guests: true, deactivated: false, }; http.when("GET", "/_synapse/admin/v2/users").respond(200, (path, _content, req) => { expect(req.queryParams).toEqual(request); expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users`); return response; }); const [result] = await Promise.all([client.listUsers( request.from, request.limit, request.name, request.guests, request.deactivated, ), http.flushAllExpected()]); expect(result).toEqual(response); }); }); describe('listAllUsers', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const user1 = { name: "@someone:example.org", displayname: "foobar", avatar_url: "mxc://example.org/animage", admin: 1, deactivated: 0, is_guest: 0, user_type: null, password_hash: "$hashbrown", }; const user2 = { name: "@else:example.org", displayname: "barbaz", avatar_url: "mxc://example.org/animage2", admin: 1, deactivated: 0, is_guest: 0, user_type: null, password_hash: "$mmmm-hashbrown", }; const request = { limit: 1, name: "bar", guests: true, deactivated: false, }; http.when("GET", "/_synapse/admin/v2/users").respond(200, (path, _content, req) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users`); expect(req.queryParams).toEqual(request); return { next_token: 'from-token', total: 2, users: [user1], }; }); const iterable = await client.listAllUsers({ name: "bar", guests: true, deactivated: false, limit: 1 }); const flush = http.flushAllExpected(); const resultUser1 = await iterable.next(); expect(resultUser1).toEqual({ done: false, value: user1 }); http.when("GET", "/_synapse/admin/v2/users").respond(200, (path, _content, req) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/users`); expect(req.queryParams).toEqual({ ...request, from: 'from-token' }); return { total: 2, users: [user2], }; }); const resultUser2 = await iterable.next(); expect(resultUser2).toEqual({ done: false, value: user2 }); expect(await iterable.next()).toEqual({ done: true }); await flush; }); }); describe('listRooms', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const response: SynapseRoomList = { rooms: [{ room_id: "!room:example.org", canonical_alias: "#room:example.org", creator: "@alice:example.org", encryption: "org.example.algorithm", federatable: true, guest_access: "can_join", history_visibility: "world_readable", join_rules: "public", joined_local_members: 1, joined_members: 2, name: "Test Room", public: true, state_events: 43, version: "6", }], next_batch: "next", offset: "prev", prev_batch: "prev", total_rooms: 1, }; const request = { search_term: "lookup", from: "from", limit: 1000, order_by: SynapseRoomProperty.CanFederate, dir: "b", }; http.when("GET", "/_synapse/admin/v1/rooms").respond(200, (path, _content, req) => { expect(req.queryParams).toEqual(request); expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/rooms`); return response; }); const [result] = await Promise.all([client.listRooms( request.search_term, request.from, request.limit, request.order_by, request.dir === 'b', ), http.flushAllExpected()]); expect(result).toEqual(response); }); describe('getRoomState', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const roomId = "!room:example.org"; const state = [ { type: "m.room.create", content: {}, state_key: "" }, { type: "m.room.member", content: { membership: "join" }, state_key: "@alice:example.org" }, { type: "m.room.member", content: { membership: "leave" }, state_key: "@bob:example.org" }, ]; http.when("GET", "/_synapse/admin/v1/rooms").respond(200, (path, _content, req) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/state`); return { state }; }); const [result] = await Promise.all([client.getRoomState(roomId), http.flushAllExpected()]); expect(result).toMatchObject(state); }); }); describe('deleteRoom', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const roomId = "!room:example.org"; http.when("DELETE", "/_synapse/admin/v2/rooms").respond(200, (path, _content, req) => { expect(JSON.parse(req.rawData)).toMatchObject({ purge: true }); expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}`); return {}; }); await Promise.all([client.deleteRoom(roomId), http.flushAllExpected()]); }); }); describe('getDeleteRoomState', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const roomId = "!room:example.org"; const state = [ { "delete_id": "delete_id1", "status": "failed", "error": "error message", "shutdown_room": { "kicked_users": [], "failed_to_kick_users": [], "local_aliases": [], "new_room_id": null, }, }, { "delete_id": "delete_id2", "status": "purging", "shutdown_room": { "kicked_users": [ "@foobar:example.com", ], "failed_to_kick_users": [], "local_aliases": [ "#badroom:example.com", "#evilsaloon:example.com", ], "new_room_id": "!newroomid:example.com", }, }, ]; http.when("GET", "/_synapse/admin/v2/rooms").respond(200, (path, _content, req) => { expect(path).toEqual(`${hsUrl}/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}/delete_status`); return { results: state }; }); const [result] = await Promise.all([client.getDeleteRoomState(roomId), http.flushAllExpected()]); expect(result).toMatchObject(state); }); }); describe('listRegistrationTokens', () => { it('should call the right endpoint', async () => { const { client, http } = createTestSynapseAdminClient(); const tokens: SynapseRegistrationToken[] = [ { token: "foo", uses_allowed: null, pending: 5, completed: 25, expiry_time: null }, { token: "bar", uses_allowed: 15, pending: 5, completed: 8, expiry_time: 10000000 }, ]; http.when("GET", "/_synapse/admin/v1/registration_tokens").respond(200, () => { return { registration_tokens: tokens }; }); const [result] = await Promise.all([client.listRegistrationTokens(), http.flushAllExpected()]); expect(result).toEqual(tokens); }); }); describe('getRegistrationToken', () => { it('should call the right endpoint', async () => { const { client, http } = createTestSynapseAdminClient(); const token: SynapseRegistrationToken = { token: "foo", uses_allowed: null, pending: 5, completed: 25, expiry_time: null, }; http.when("GET", "/_synapse/admin/v1/registration_tokens/foo").respond(200, () => { return token; }); http.when("GET", "/_synapse/admin/v1/registration_tokens/not-a-token").respond(404, (path) => { return { errcode: "M_NOT_FOUND", error: "No such registration token: not-a-token", }; }); const flush = http.flushAllExpected(); const result = await client.getRegistrationToken(token.token); expect(result).toEqual(token); const resultNull = await client.getRegistrationToken("not-a-token"); expect(resultNull).toEqual(null); await flush; }); }); describe('createRegistrationToken', () => { it('should call the right endpoint', async () => { const { client, http } = createTestSynapseAdminClient(); const responseToken: SynapseRegistrationToken = { token: "foo", uses_allowed: null, pending: 5, completed: 25, expiry_time: null, }; const options: SynapseRegistrationTokenOptions = { token: "foo", uses_allowed: null, }; http.when("POST", "/_synapse/admin/v1/registration_tokens/new").respond(200, (_path, content) => { expect(options).toMatchObject(content); return responseToken; }); const [result] = await Promise.all([client.createRegistrationToken(options), http.flushAllExpected()]); expect(result).toEqual(responseToken); }); }); describe('updateRegistrationToken', () => { it('should call the right endpoint', async () => { const { client, http } = createTestSynapseAdminClient(); const responseToken: SynapseRegistrationToken = { token: "foo", uses_allowed: null, pending: 5, completed: 25, expiry_time: null, }; const options: SynapseRegistrationTokenUpdateOptions = { uses_allowed: null, }; http.when("PUT", "/_synapse/admin/v1/registration_tokens/foo").respond(200, (_path, content) => { expect(options).toMatchObject(content); return responseToken; }); const [result] = await Promise.all([client.updateRegistrationToken("foo", options), http.flushAllExpected()]); expect(result).toEqual(responseToken); }); }); describe('deleteRegistrationToken', () => { it('should call the right endpoint', async () => { const { client, http } = createTestSynapseAdminClient(); http.when("DELETE", "/_synapse/admin/v1/registration_tokens/foo").respond(200, () => { return {}; }); await Promise.all([client.deleteRegistrationToken("foo"), http.flushAllExpected()]); }); }); describe('makeRoomAdmin', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestSynapseAdminClient(); const roomId = "!room:example.org"; const userId = "@alice:example.org"; http.when("POST", "/_synapse/admin/v1/rooms").respond(200, (path, content, req) => { expect(content).toMatchObject({ user_id: userId }); expect(path).toEqual(`${hsUrl}/_synapse/admin/v1/rooms/${encodeURIComponent(roomId)}/make_room_admin`); return {}; }); await Promise.all([client.makeRoomAdmin(roomId, userId), http.flushAllExpected()]); }); }); }); }); ================================================ FILE: test/SynchronousMatrixClientTest.ts ================================================ import * as simple from "simple-mock"; import HttpBackend from 'matrix-mock-request'; import { IStorageProvider, MatrixClient, setRequestFn, SynchronousMatrixClient } from "../src"; class TestSyncMatrixClient extends SynchronousMatrixClient { constructor(client: MatrixClient) { super(client); } public async doProcessSync(raw: any) { // HACK: We shouldn't have to do this, and should be testing the startSyncInterval function const fn = ( this).handleEvent.bind(this); return super.processSync(raw, fn); } } export function createSyncTestClient(storage: IStorageProvider = null): { client: TestSyncMatrixClient, http: HttpBackend, hsUrl: string, accessToken: string } { const http = new HttpBackend(); const hsUrl = "https://localhost"; const accessToken = "s3cret"; const client = new MatrixClient(hsUrl, accessToken, storage); setRequestFn(http.requestFn); return { http, hsUrl, accessToken, client: new TestSyncMatrixClient(client) }; } describe('SynchronousMatrixClient', () => { describe('processSync', () => { interface ProcessSyncClient { userId: string; } it('should process non-room account data', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((ev) => { expect(ev).toMatchObject(events[0]); }); const syncSpy = simple.mock(realClient, 'onAccountData').callFn((ev) => { expect(ev).toMatchObject(events[0]); }); realClient.on("account_data", spy); await realClient.doProcessSync({ account_data: { events: events } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process left rooms', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomLeave').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process left rooms account data', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomAccountData').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.account_data", spy); const roomsObj = {}; roomsObj[roomId] = { account_data: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should use the most recent leave event', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 2 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 3 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomLeave').callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should not be affected by irrelevant events during leaves', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.not_member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "leave" }, }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 1 }, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomLeave').callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should not process leaves detached from events', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.not_member", state_key: userId, unsigned: { age: 1 }, }, // Intentionally don't include a membership event // { // type: "m.room.member", // state_key: userId, // unsigned: {age: 1}, // content: { membership: "leave" }, // }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 1 }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { // expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomLeave').callFn((rid, ev) => { // expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(0); expect(syncSpy.callCount).toBe(0); }); it('should not get hung up on not having an age available for leaves', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, content: { membership: "leave" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomLeave').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.leave", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { leave: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process room invites', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomInvite').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should use the most recent invite event', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.member", state_key: userId, unsigned: { age: 2 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 1 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 3 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomInvite').callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should not be affected by irrelevant events during invites', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.not_member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomInvite').callFn((rid, ev) => { expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should not process invites detached from events', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.not_member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, // Intentionally don't send a membership event // { // type: "m.room.member", // state_key: userId, // unsigned: {age: 0}, // content: {membership: "invite"}, // }, { type: "m.room.member", state_key: userId + "_wrong_member", unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { // expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomInvite').callFn((rid, ev) => { // expect(ev).toMatchObject(events[1]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(0); expect(syncSpy.callCount).toBe(0); }); it('should not get hung up by not having an age available for invites', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ // TODO: Surely the 'invite' membership should be in some sort of content field? { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "invite" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomInvite').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.invite", spy); const roomsObj = {}; roomsObj[roomId] = { invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { invite: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process room joins', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomJoin').callFn((rid) => { expect(rid).toEqual(roomId); }); realClient.on("room.join", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process joined room account data', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", content: { example: true, }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomAccountData').callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); realClient.on("room.account_data", spy); const roomsObj = {}; roomsObj[roomId] = { account_data: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should not duplicate room joins', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, ]; client.userId = userId; const spy = simple.stub().callFn((rid, ev) => { expect(ev).toMatchObject(events[0]); expect(rid).toEqual(roomId); }); const syncSpy = simple.mock(realClient, 'onRoomJoin').callFn((rid) => { expect(rid).toEqual(roomId); }); realClient.on("room.join", spy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); await realClient.doProcessSync({ rooms: { join: roomsObj } }); expect(spy.callCount).toBe(1); expect(syncSpy.callCount).toBe(1); }); it('should process events for joined rooms', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.not_message", content: { body: "hello world 1" }, }, { type: "m.room.message", content: { body: "hello world 2" }, }, { type: "m.room.not_message", content: { body: "hello world 3" }, }, { type: "m.room.message", content: { body: "hello world 4" }, }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.message"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); const syncJoinSpy = simple.mock(realClient, 'onRoomJoin').callFn(() => { }); const syncInviteSpy = simple.mock(realClient, 'onRoomInvite').callFn(() => { }); const syncLeaveSpy = simple.mock(realClient, 'onRoomLeave').callFn(() => { }); const syncMessageSpy = simple.mock(realClient, 'onRoomMessage').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.message"); }); const syncEventSpy = simple.mock(realClient, 'onRoomEvent').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.message", messageSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(syncJoinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(syncInviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(syncLeaveSpy.callCount).toBe(0); expect(messageSpy.callCount).toBe(2); expect(syncMessageSpy.callCount).toBe(2); expect(eventSpy.callCount).toBe(5); expect(syncEventSpy.callCount).toBe(5); }); it('should process tombstone events', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.tombstone", content: { body: "hello world 1" }, state_key: "", }, { type: "m.room.create", content: { predecessor: { room_id: "!old:example.org" } }, state_key: "", }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const archiveSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.tombstone"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); const syncJoinSpy = simple.mock(realClient, 'onRoomJoin').callFn(() => { }); const syncInviteSpy = simple.mock(realClient, 'onRoomInvite').callFn(() => { }); const syncLeaveSpy = simple.mock(realClient, 'onRoomLeave').callFn(() => { }); const syncArchiveSpy = simple.mock(realClient, 'onRoomArchived').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.tombstone"); }); const syncEventSpy = simple.mock(realClient, 'onRoomEvent').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.archived", archiveSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(syncJoinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(syncInviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(syncLeaveSpy.callCount).toBe(0); expect(archiveSpy.callCount).toBe(1); expect(syncArchiveSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(3); expect(syncEventSpy.callCount).toBe(3); }); it('should process create events with a predecessor', async () => { const { client: realClient } = createSyncTestClient(); const client = (realClient); const userId = "@syncing:example.org"; const roomId = "!testing:example.org"; const events = [ { type: "m.room.member", state_key: userId, unsigned: { age: 0 }, content: { membership: "join" }, }, { type: "m.room.tombstone", content: { body: "hello world 1" }, state_key: "", }, { type: "m.room.create", content: { predecessor: { room_id: "!old:example.org" } }, state_key: "", }, ]; client.userId = userId; const joinSpy = simple.stub(); const inviteSpy = simple.stub(); const leaveSpy = simple.stub(); const upgradedSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.create"); }); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); const syncJoinSpy = simple.mock(realClient, 'onRoomJoin').callFn(() => { }); const syncInviteSpy = simple.mock(realClient, 'onRoomInvite').callFn(() => { }); const syncLeaveSpy = simple.mock(realClient, 'onRoomLeave').callFn(() => { }); const syncUpgradedSpy = simple.mock(realClient, 'onRoomUpgraded').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); expect(ev["type"]).toEqual("m.room.create"); }); const syncEventSpy = simple.mock(realClient, 'onRoomEvent').callFn((rid, ev) => { expect(rid).toEqual(roomId); expect(events).toContain(ev); }); realClient.on("room.join", joinSpy); realClient.on("room.invite", inviteSpy); realClient.on("room.leave", leaveSpy); realClient.on("room.upgraded", upgradedSpy); realClient.on("room.event", eventSpy); const roomsObj = {}; roomsObj[roomId] = { timeline: { events: events }, invite_state: { events: events } }; await realClient.doProcessSync({ rooms: { join: roomsObj, leave: roomsObj, invite: roomsObj } }); expect(joinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(syncJoinSpy.callCount).toBe(1); // We'll technically be joining the room for the first time expect(inviteSpy.callCount).toBe(0); expect(syncInviteSpy.callCount).toBe(0); expect(leaveSpy.callCount).toBe(0); expect(syncLeaveSpy.callCount).toBe(0); expect(upgradedSpy.callCount).toBe(1); expect(syncUpgradedSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(3); expect(syncEventSpy.callCount).toBe(3); }); }); }); ================================================ FILE: test/TestUtils.ts ================================================ import * as tmp from "tmp"; import HttpBackend from "matrix-mock-request"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { IStorageProvider, MatrixClient, RustSdkCryptoStorageProvider, setRequestFn } from "../src"; export const TEST_DEVICE_ID = "TEST_DEVICE"; export function expectArrayEquals(expected: any[], actual: any[]) { expect(expected).toBeDefined(); expect(actual).toBeDefined(); expect(actual.length).toBe(expected.length); for (let i = 0; i < actual.length; i++) { expect(actual[i]).toEqual(expected[i]); } } export type Constructor = { new(...args: any[]): T }; export function expectInstanceOf(expected: Constructor, actual: any): boolean { return actual instanceof expected; } export function testDelay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } export function createTestClient( storage: IStorageProvider = null, userId: string = null, cryptoStoreType?: StoreType, ): { client: MatrixClient; http: HttpBackend; hsUrl: string; accessToken: string; } { const http = new HttpBackend(); const hsUrl = "https://localhost"; const accessToken = "s3cret"; const client = new MatrixClient(hsUrl, accessToken, storage, cryptoStoreType !== undefined ? new RustSdkCryptoStorageProvider(tmp.dirSync().name, cryptoStoreType) : null); (client).userId = userId; // private member access setRequestFn(http.requestFn); return { http, hsUrl, accessToken, client }; } const CRYPTO_STORE_TYPES = [StoreType.Sqlite]; export async function testCryptoStores(fn: (StoreType) => Promise): Promise { for (const st of CRYPTO_STORE_TYPES) { await fn(st); } } ================================================ FILE: test/UnstableApisTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import { IStorageProvider, MatrixClient, MSC2380MediaInfo, UnstableApis } from "../src"; import { createTestClient } from "./TestUtils"; export function createTestUnstableClient( storage: IStorageProvider = null, ): { client: UnstableApis; mxClient: MatrixClient; http: HttpBackend; hsUrl: string; accessToken: string; } { const result = createTestClient(storage); const mxClient = result.client; const client = new UnstableApis(mxClient); delete result.client; return { ...result, client, mxClient }; } describe('UnstableApis', () => { describe('getRoomAliases', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const aliases = ["#test:example.org", "#test2:example.org"]; const roomId = "!room:example.org"; http.when("GET", "/_matrix/client/unstable/org.matrix.msc2432/rooms").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/unstable/org.matrix.msc2432/rooms/${encodeURIComponent(roomId)}/aliases`); return { aliases: aliases }; }); const [result] = await Promise.all([client.getRoomAliases(roomId), http.flushAllExpected()]); expect(result).toMatchObject(aliases); }); }); describe('addReactionToEvent', () => { it('should send an m.reaction event', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const roomId = "!test:example.org"; const originalEventId = "$orig:example.org"; const newEventId = "$new:example.org"; const emoji = "😀"; const expectedReaction = { "m.relates_to": { event_id: originalEventId, key: emoji, rel_type: "m.annotation", }, }; http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.reaction/`); expect(idx).toBe(0); expect(content).toMatchObject(expectedReaction); return { event_id: newEventId }; }); const [result] = await Promise.all([client.addReactionToEvent(roomId, originalEventId, emoji), http.flushAllExpected()]); expect(result).toEqual(newEventId); }); }); describe('getRelationsForEvent', () => { test.each([ [null, null], ['org.example.relation', null], ['org.example.relation', 'org.example.event_type'], ])("should call the right endpoint for rel=%p and type=%p", async (relType, eventType) => { const { client, http, hsUrl } = createTestUnstableClient(); const roomId = "!room:example.org"; const eventId = "$event"; const response = { chunk: [ { eventContents: true }, { eventContents: true }, { eventContents: true }, ], }; http.when("GET", "/_matrix/client/unstable/rooms").respond(200, (path, content) => { const relTypeComponent = relType ? `/${encodeURIComponent(relType)}` : ''; const eventTypeComponent = eventType ? `/${encodeURIComponent(eventType)}` : ''; // eslint-disable-next-line max-len const idx = path.indexOf(`${hsUrl}/_matrix/client/unstable/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}${relTypeComponent}${eventTypeComponent}`); expect(idx).toBe(0); return response; }); const [result] = await Promise.all([client.getRelationsForEvent(roomId, eventId, relType, eventType), http.flushAllExpected()]); expect(result).toEqual(response); }); }); describe('getMediaInfo', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const domain = "example.org"; const mediaId = "abc123"; const mxc = `mxc://${domain}/${mediaId}`; const response: MSC2380MediaInfo = { content_type: "image/png", size: 12, }; http.when("GET", "/_matrix/media/unstable/info").respond(200, (path, content) => { const idx = path.indexOf(`${hsUrl}/_matrix/media/unstable/info/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`); expect(idx).toBe(0); return response; }); const [result] = await Promise.all([client.getMediaInfo(mxc), http.flushAllExpected()]); expect(result).toEqual(response); }); test.each([ ["invalid", "'mxcUrl' does not begin with mxc://"], ["mxc://", "Missing domain or media ID"], ["mxc://domainonly", "Missing domain or media ID"], ["mxc://emptymedia/", "Missing domain or media ID"], ])("should fail if the MXC URI is invalid: %p / %p", async (val, err) => { const { client } = createTestUnstableClient(); await expect(client.getMediaInfo(val)).rejects.toThrow(err); }); }); }); ================================================ FILE: test/appservice/AppserviceTest.ts ================================================ import * as getPort from "get-port"; import * as requestPromise from "request-promise"; import * as simple from "simple-mock"; import HttpBackend from 'matrix-mock-request'; import { Appservice, EventKind, Intent, IPreprocessor, setRequestFn } from "../../src"; async function beginAppserviceWithProtocols(protocols: string[]) { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, protocols, }, }); appservice.botIntent.ensureRegistered = () => { return Promise.resolve(); }; async function doCall(route: string, opts: any = {}, qs: any = {}) { return await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken, ...qs }, json: true, ...opts, }); } await appservice.begin(); return { appservice, doCall }; } describe('Appservice', () => { it('should throw when there are no registered namespaces', async () => { try { new Appservice({ port: 0, bindAddress: '', homeserverName: 'localhost', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "", namespaces: { users: [], rooms: [], aliases: [], }, }, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("No user namespaces in registration"); } }); it('should throw when there are too many registered namespaces', async () => { try { new Appservice({ port: 0, bindAddress: '', homeserverName: 'localhost', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "", namespaces: { users: [ { exclusive: true, regex: "@.+:.+" }, { exclusive: true, regex: "@.+:.+" }, ], rooms: [], aliases: [], }, }, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("Too many user namespaces registered: expecting exactly one"); } }); it('should accept a ".+" prefix namespace', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'localhost', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "", namespaces: { users: [{ exclusive: true, regex: "@prefix_.+:localhost" }], rooms: [], aliases: [], }, }, }); expect(appservice.getUserIdForSuffix('foo')).toEqual("@prefix_foo:localhost"); }); it('should accept a ".*" prefix namespace', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'localhost', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "", namespaces: { users: [{ exclusive: true, regex: "@prefix_.*:localhost" }], rooms: [], aliases: [], }, }, }); expect(appservice.getUserIdForSuffix('foo')).toEqual("@prefix_foo:localhost"); }); it('should allow disabling the suffix check', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'localhost', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "", namespaces: { users: [{ exclusive: true, regex: "@prefix_foo:localhost" }], rooms: [], aliases: [], }, }, }); expect(() => appservice.getUserIdForSuffix('foo')).toThrow("Cannot use getUserIdForSuffix, provided namespace did not include a valid suffix"); expect(() => appservice.getSuffixForUserId('foo')).toThrow("Cannot use getUserIdForSuffix, provided namespace did not include a valid suffix"); expect(appservice.isNamespacedUser('@prefix_foo:localhost')).toEqual(true); }); it('should return the right bot user ID', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.botUserId).toEqual("@_bot_:example.org"); }); it('should return the express app running the webserver', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const instance = appservice.expressAppInstance; expect(instance).toBeDefined(); }); it('should return the bridge APIs for the appservice', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const instance = appservice.bridge; expect(instance).toBeDefined(); }); it('should return an intent for the bot user', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const intent = appservice.botIntent; expect(intent).toBeDefined(); expect(intent.userId).toEqual(appservice.botUserId); }); it('should return a client for the bot user', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const intent = appservice.botClient; expect(intent).toBeDefined(); }); it('should be able to tell if a given user is the prefix namespace', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const userA = "@_prefix_test:example.org"; const userB = "@alice_prefix_:example.org"; expect(appservice.isNamespacedUser(userA)).toBeTruthy(); expect(appservice.isNamespacedUser(userB)).toBeFalsy(); expect(appservice.isNamespacedUser("@_bot_:example.org")).toBeTruthy(); }); it('should return an intent for any namespaced localpart', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const intent = appservice.getIntent("_prefix_testing"); expect(intent).toBeDefined(); expect(intent.userId).toEqual("@_prefix_testing:example.org"); }); it('should return an intent for any namespaced suffix', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const intent = appservice.getIntentForSuffix("testing"); expect(intent).toBeDefined(); expect(intent.userId).toEqual("@_prefix_testing:example.org"); }); it('should return an intent for any user ID', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); let intent: Intent; let userId: string; userId = "@alice:example.org"; intent = appservice.getIntentForUserId(userId); expect(intent).toBeDefined(); expect(intent.userId).toEqual(userId); userId = "@_prefix_testing:example.org"; intent = appservice.getIntentForUserId(userId); expect(intent).toBeDefined(); expect(intent.userId).toEqual(userId); userId = "@_bot_:example.org"; intent = appservice.getIntentForUserId(userId); expect(intent).toBeDefined(); expect(intent.userId).toEqual(userId); userId = "@test_prefix_:example.org"; intent = appservice.getIntentForUserId(userId); expect(intent).toBeDefined(); expect(intent.userId).toEqual(userId); }); it('should return a user ID for any namespaced localpart', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getUserId("_prefix_testing")).toEqual("@_prefix_testing:example.org"); }); it('should return a user ID for any namespaced suffix', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getUserIdForSuffix("testing")).toEqual("@_prefix_testing:example.org"); }); describe('getSuffixForUserId', () => { it('should return a suffix for any namespaced user ID', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const suffix = "testing"; const userId = `@_prefix_${suffix}:example.org`; expect(appservice.getSuffixForUserId(userId)).toBe(suffix); }); it('should return a falsey suffix for any non-namespaced user ID', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getSuffixForUserId(null)).toBeFalsy(); expect(appservice.getSuffixForUserId(undefined)).toBeFalsy(); expect(appservice.getSuffixForUserId("")).toBeFalsy(); expect(appservice.getSuffixForUserId("@invalid")).toBeFalsy(); expect(appservice.getSuffixForUserId("@_prefix_invalid")).toBeFalsy(); expect(appservice.getSuffixForUserId("@_prefix_testing:invalid.example.org")).toBeFalsy(); expect(appservice.getSuffixForUserId("@_invalid_testing:example.org")).toBeFalsy(); }); }); describe('isNamespacedAlias', () => { it('should throw on no alias prefix set', async () => { try { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const userA = "#_prefix_test:example.org"; const userB = "#alice_prefix_:example.org"; expect(appservice.isNamespacedAlias(userA)).toBeTruthy(); expect(appservice.isNamespacedAlias(userB)).toBeFalsy(); throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("Invalid configured alias prefix"); } }); it('should be able to tell if a given alias is the prefix namespace', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [{ exclusive: true, regex: "#_prefix_.*:.+" }], }, }, }); const userA = "#_prefix_test:example.org"; const userB = "#alice_prefix_:example.org"; expect(appservice.isNamespacedAlias(userA)).toBeTruthy(); expect(appservice.isNamespacedAlias(userB)).toBeFalsy(); }); }); it('should return a alias for any namespaced localpart', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getAlias("_prefix_testing")).toEqual("#_prefix_testing:example.org"); }); describe('getAliasForSuffix', () => { it('should throw on no alias prefix set', async () => { try { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getAliasForSuffix("testing")).toEqual("#_prefix_testing:example.org"); throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("Invalid configured alias prefix"); } }); it('should return an alias for any namespaced suffix', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [{ exclusive: true, regex: "#_prefix_.*:.+" }], }, }, }); expect(appservice.getAliasForSuffix("testing")).toEqual("#_prefix_testing:example.org"); }); }); describe('getAliasLocalpartForSuffix', () => { it('should throw on no alias prefix set', async () => { try { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); expect(appservice.getAliasLocalpartForSuffix("testing")).toEqual("_prefix_testing"); throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("Invalid configured alias prefix"); } }); it('should return an alias localpart for any namespaced suffix', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [{ exclusive: true, regex: "#_prefix_.*:.+" }], }, }, }); expect(appservice.getAliasLocalpartForSuffix("testing")).toEqual("_prefix_testing"); }); }); describe('getSuffixForAlias', () => { it('should throw on no alias prefix set', async () => { try { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const suffix = "testing"; const userId = `#_prefix_${suffix}:example.org`; expect(appservice.getSuffixForAlias(userId)).toBe(suffix); throw new Error("Did not throw when expecting it"); } catch (e) { expect(e.message).toEqual("Invalid configured alias prefix"); } }); it('should return a suffix for any namespaced alias', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [{ exclusive: true, regex: "#_prefix_.*:.+" }], }, }, }); const suffix = "testing"; const userId = `#_prefix_${suffix}:example.org`; expect(appservice.getSuffixForAlias(userId)).toBe(suffix); }); it('should return a falsey suffix for any non-namespaced alias', async () => { const appservice = new Appservice({ port: 0, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [{ exclusive: true, regex: "#_prefix_.*:.+" }], }, }, }); expect(appservice.getSuffixForAlias(null)).toBeFalsy(); expect(appservice.getSuffixForAlias(undefined)).toBeFalsy(); expect(appservice.getSuffixForAlias("")).toBeFalsy(); expect(appservice.getSuffixForAlias("#invalid")).toBeFalsy(); expect(appservice.getSuffixForAlias("#_prefix_invalid")).toBeFalsy(); expect(appservice.getSuffixForAlias("#_prefix_testing:invalid.example.org")).toBeFalsy(); expect(appservice.getSuffixForAlias("#_invalid_testing:example.org")).toBeFalsy(); }); }); it('should return 404 error codes for unknown endpoints', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { // Should not be 200 OK await requestPromise({ uri: `http://localhost:${port}/this/is/not/a/valid/api`, method: "PUT", json: { events: [] }, headers: { Authorization: `Bearer ${hsToken}`, }, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request passed when it shouldn't have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it('should 401 requests with bad auth', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { async function verifyAuth(method: string, route: string) { async function doCall(opts: any = {}) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: method, json: true, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Authentication passed when it shouldn't have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "AUTH_FAILED", error: "Authentication failed", }); expect(e.statusCode).toBe(401); } } await doCall(); await doCall({ qs: { access_token: "WRONG_TOKEN" } }); await doCall({ headers: { Authorization: "Bearer WRONG_TOKEN" } }); await doCall({ headers: { Authorization: "NotBearer WRONG_TOKEN" } }); } await verifyAuth("GET", "/users/@_prefix_sample:example.org"); await verifyAuth("GET", "/rooms/" + encodeURIComponent("#_prefix_sample:example.org")); await verifyAuth("PUT", "/transactions/txnId"); await verifyAuth("GET", "/_matrix/app/v1/users/@_prefix_sample:example.org"); await verifyAuth("GET", "/_matrix/app/v1/rooms/" + encodeURIComponent("#_prefix_sample:example.org")); await verifyAuth("PUT", "/_matrix/app/v1/transactions/txnId"); await verifyAuth("GET", "/_matrix/app/v1/thirdparty/protocol/protocolId"); await verifyAuth("GET", "/_matrix/app/v1/thirdparty/user/protocolId"); await verifyAuth("GET", "/_matrix/app/v1/thirdparty/user"); await verifyAuth("GET", "/_matrix/app/v1/thirdparty/location/protocolId"); await verifyAuth("GET", "/_matrix/app/v1/thirdparty/location"); await verifyAuth("POST", "/_matrix/app/unstable/org.matrix.msc3983/keys/claim"); await verifyAuth("POST", "/_matrix/app/unstable/org.matrix.msc3984/keys/query"); } finally { appservice.stop(); } }); it('should support using the Authorization header for auth', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { // Should return 200 OK await requestPromise({ uri: `http://localhost:${port}/_matrix/app/v1/transactions/1`, method: "PUT", json: { events: [] }, headers: { Authorization: `Bearer ${hsToken}`, }, }); try { // Should not be 200 OK await requestPromise({ uri: `http://localhost:${port}/_matrix/app/v1/transactions/1`, method: "PUT", json: { events: [] }, headers: { Authorization: `IMPROPER_AUTH ${hsToken}`, }, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Authentication passed when it shouldn't have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "AUTH_FAILED", error: "Authentication failed", }); expect(e.statusCode).toBe(401); } } finally { appservice.stop(); } }); it('should validate inputs for transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { async function doCall(route: string, opts: any = {}, err: any) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request passed when it shouldn't have"); } catch (e) { expect(e.error).toMatchObject(err); expect(e.statusCode).toBe(400); } } await doCall("/transactions/1", { json: { hello: "world" } }, { errcode: "BAD_REQUEST", error: "Invalid JSON: expected events", }); await doCall("/_matrix/app/v1/transactions/1", { json: { hello: "world" } }, { errcode: "BAD_REQUEST", error: "Invalid JSON: expected events", }); } finally { appservice.stop(); } }); it('should emit events from transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.message", roomId: "!somewhere:example.org" }, { type: "m.room.not_message", roomId: "!elsewhere:example.org" }, ], }; const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); if (ev["type"] === "m.room.message") expect(ev).toMatchObject(txnBody.events[0]); else expect(ev).toMatchObject(txnBody.events[1]); }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); expect(ev).toMatchObject(txnBody.events[0]); }); appservice.on("room.event", eventSpy); appservice.on("room.message", messageSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); expect(messageSpy.callCount).toBe(1); eventSpy.callCount = 0; messageSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should emit ephemeral events from transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, // "de.sorunome.msc2409.push_ephemeral": true, // Shouldn't affect emission }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { "events": [ { type: "m.room.message", roomId: "!somewhere:example.org" }, { type: "m.room.not_message", roomId: "!elsewhere:example.org" }, ], "de.sorunome.msc2409.ephemeral": [ { type: "m.typing", userId: "@someone:example.org" }, { type: "m.not_typing", userId: "@someone_else:example.org" }, ], }; const eventSpy = simple.stub().callFn((ev) => { if (ev["type"] === "m.typing") expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][0]); else expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][1]); }); appservice.on("ephemeral.event", eventSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); eventSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should emit MSC3202 extensions from transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, // "de.sorunome.msc2409.push_ephemeral": true, // Shouldn't affect emission }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const sampleEncryptedEvent = { type: "m.room.encrypted", room_id: "!room:example.org", // ... and other fields }; let txnBody: any = { "events": [], }; const deviceListSpy = simple.stub().callFn((lists) => { expect(lists).toMatchObject(txnBody["org.matrix.msc3202.device_lists"]); }); const otkSpy = simple.stub().callFn((otks) => { expect(otks).toStrictEqual(txnBody["org.matrix.msc3202.device_one_time_keys_count"]); }); const fallbackKeySpy = simple.stub().callFn((fbKeys) => { expect(fbKeys).toStrictEqual(txnBody["org.matrix.msc3202.device_unused_fallback_key_types"]); }); const encryptedEventSpy = simple.stub().callFn((roomId, ev) => { expect(roomId).toBe(sampleEncryptedEvent.room_id); expect(ev).toStrictEqual(sampleEncryptedEvent); }); appservice.on("device_lists", deviceListSpy); appservice.on("otk.counts", otkSpy); appservice.on("otk.unused_fallback_keys", fallbackKeySpy); appservice.on("room.encrypted_event", encryptedEventSpy); let txnId = 1; async function doCall(route: string, spyCallback: () => void) { const res = await requestPromise({ uri: `http://localhost:${port}${route}${txnId++}`, method: "PUT", qs: { access_token: hsToken }, json: txnBody, }); expect(res).toMatchObject({}); spyCallback(); deviceListSpy.callCount = 0; otkSpy.callCount = 0; fallbackKeySpy.callCount = 0; encryptedEventSpy.callCount = 0; } async function checkBothPaths(spyCallback: () => void) { await doCall("/transactions/", spyCallback); await doCall("/_matrix/app/v1/transactions/", spyCallback); } // Check 1: doesn't fire anything when nothing is happening txnBody = { "events": [], }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(0); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 2: Device lists fire for changed lists txnBody = { "events": [], "org.matrix.msc3202.device_lists": { "changed": ["@alice:example.org"], }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(1); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 3: Device lists fire for removed lists txnBody = { "events": [], "org.matrix.msc3202.device_lists": { "removed": ["@alice:example.org"], }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(1); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 4: Device lists fire for changed and removed lists txnBody = { "events": [], "org.matrix.msc3202.device_lists": { "changed": ["@alice:example.org"], "removed": ["@bob:example.org"], }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(1); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 5: OTKs fire txnBody = { "events": [], "org.matrix.msc3202.device_one_time_keys_count": { "@alice:example.org": { "DEVICEID": { "curve25519": 10, "signed_curve25519": 20, }, }, }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(0); expect(otkSpy.callCount).toBe(1); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 6: Fallback keys fire txnBody = { "events": [], "org.matrix.msc3202.device_unused_fallback_key_types": { "@alice:example.org": { "DEVICEID": ["signed_curve25519"], }, }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(0); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(1); expect(encryptedEventSpy.callCount).toBe(0); }); // Check 7: Encrypted event received fires txnBody = { "events": [sampleEncryptedEvent], }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(0); expect(otkSpy.callCount).toBe(0); expect(fallbackKeySpy.callCount).toBe(0); expect(encryptedEventSpy.callCount).toBe(1); }); // Check 8: It all fires txnBody = { "events": [sampleEncryptedEvent], "org.matrix.msc3202.device_lists": { "changed": ["@alice:example.org"], "removed": ["@bob:example.org"], }, "org.matrix.msc3202.device_one_time_keys_count": { "@alice:example.org": { "DEVICEID": { "curve25519": 10, "signed_curve25519": 20, }, }, }, "org.matrix.msc3202.device_unused_fallback_key_types": { "@alice:example.org": { "DEVICEID": ["signed_curve25519"], }, }, }; await checkBothPaths(() => { expect(deviceListSpy.callCount).toBe(1); expect(otkSpy.callCount).toBe(1); expect(fallbackKeySpy.callCount).toBe(1); expect(encryptedEventSpy.callCount).toBe(1); }); } finally { appservice.stop(); } }); it('should not duplicate transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.message", roomId: "!somewhere:example.org" }, { type: "m.room.not_message", roomId: "!elsewhere:example.org" }, ], }; const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); if (ev["type"] === "m.room.message") expect(ev).toMatchObject(txnBody.events[0]); else expect(ev).toMatchObject(txnBody.events[1]); }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); expect(ev).toMatchObject(txnBody.events[0]); }); appservice.on("room.event", eventSpy); appservice.on("room.message", messageSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); expect(messageSpy.callCount).toBe(1); } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/1", { json: txnBody }); } finally { appservice.stop(); } }); it('should send transaction events through a processor', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.message", roomId: "!somewhere:example.org" }, { type: "m.room.not_message", roomId: "!elsewhere:example.org" }, ], }; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.room.member", "m.room.message", "m.room.not_message"], }; appservice.addPreprocessor(processor); const processorSpy = simple.mock(processor, "processEvent").callOriginal(); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); if (ev["type"] === "m.room.message") expect(ev).toMatchObject(txnBody.events[0]); else expect(ev).toMatchObject(txnBody.events[1]); }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); expect(ev).toMatchObject(txnBody.events[0]); }); appservice.on("room.event", eventSpy); appservice.on("room.message", messageSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); expect(messageSpy.callCount).toBe(1); expect(processorSpy.callCount).toBe(2); eventSpy.callCount = 0; messageSpy.callCount = 0; processorSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should send transaction ephemeral events through a processor', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { "as_token": "", "hs_token": hsToken, "sender_localpart": "_bot_", "namespaces": { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, "de.sorunome.msc2409.push_ephemeral": true, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { "events": [], "de.sorunome.msc2409.ephemeral": [ { type: "m.typing", userId: "@someone:example.org" }, { type: "m.not_typing", userId: "@someone_else:example.org" }, ], }; const processor = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.EphemeralEvent); ev["processed"] = true; }, getSupportedEventTypes: () => ["m.typing", "m.not_typing"], }; appservice.addPreprocessor(processor); const processorSpy = simple.mock(processor, "processEvent").callOriginal(); const eventSpy = simple.stub().callFn((ev) => { if (ev["type"] === "m.typing") expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][0]); else expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][1]); }); appservice.on("ephemeral.event", eventSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); expect(processorSpy.callCount).toBe(2); eventSpy.callCount = 0; processorSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should send transaction events through a relevant processor', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.message", roomId: "!somewhere:example.org" }, { type: "m.room.not_message", roomId: "!elsewhere:example.org" }, { type: "m.room.unknown", roomId: "!elsewhere:example.org" }, ], }; const processorA = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = "A"; }, getSupportedEventTypes: () => ["m.room.message"], }; appservice.addPreprocessor(processorA); const processorSpyA = simple.mock(processorA, "processEvent").callOriginal(); const processorB = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.RoomEvent); ev["processed"] = "B"; }, getSupportedEventTypes: () => ["m.room.not_message"], }; appservice.addPreprocessor(processorB); const processorSpyB = simple.mock(processorB, "processEvent").callOriginal(); const eventSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); if (ev["type"] === "m.room.message") { expect(ev).toMatchObject(txnBody.events[0]); expect(ev["processed"]).toEqual("A"); } else if (ev["type"] === "m.room.not_message") { expect(ev).toMatchObject(txnBody.events[1]); expect(ev["processed"]).toEqual("B"); } else { expect(ev).toMatchObject(txnBody.events[2]); } }); const messageSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(ev["room_id"]); expect(ev).toMatchObject(txnBody.events[0]); expect(ev["processed"]).toEqual("A"); }); appservice.on("room.event", eventSpy); appservice.on("room.message", messageSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(3); expect(messageSpy.callCount).toBe(1); expect(processorSpyA.callCount).toBe(1); expect(processorSpyB.callCount).toBe(1); eventSpy.callCount = 0; messageSpy.callCount = 0; processorSpyA.callCount = 0; processorSpyB.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should send transaction ephemeral events through a relevant processor', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { "as_token": "", "hs_token": hsToken, "sender_localpart": "_bot_", "namespaces": { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, "de.sorunome.msc2409.push_ephemeral": true, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { "events": [], "de.sorunome.msc2409.ephemeral": [ { type: "m.typing", userId: "@someone:example.org" }, { type: "m.not_typing", userId: "@someone_else:example.org" }, ], }; const processorA = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.EphemeralEvent); ev["processed"] = 'A'; }, getSupportedEventTypes: () => ["m.typing"], }; appservice.addPreprocessor(processorA); const processorSpyA = simple.mock(processorA, "processEvent").callOriginal(); const processorB = { processEvent: (ev, procClient, kind?) => { expect(kind).toEqual(EventKind.EphemeralEvent); ev["processed"] = 'B'; }, getSupportedEventTypes: () => ["m.not_typing"], }; appservice.addPreprocessor(processorB); const processorSpyB = simple.mock(processorB, "processEvent").callOriginal(); const eventSpy = simple.stub().callFn((ev) => { expect(ev["processed"]).toEqual(ev["type"] === "m.typing" ? "A" : "B"); if (ev["type"] === "m.typing") expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][0]); else expect(ev).toMatchObject(txnBody["de.sorunome.msc2409.ephemeral"][1]); }); appservice.on("ephemeral.event", eventSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(eventSpy.callCount).toBe(2); expect(processorSpyA.callCount).toBe(1); expect(processorSpyB.callCount).toBe(1); eventSpy.callCount = 0; processorSpyA.callCount = 0; processorSpyB.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should handle membership events in transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const intent = appservice.getIntentForSuffix("test"); intent.refreshJoinedRooms = () => Promise.resolve([]); await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "join" }, state_key: "@_prefix_test:example.org", }, { type: "m.room.member", room_id: "!BBB:example.org", content: { membership: "leave" }, state_key: "@_prefix_test:example.org", }, { type: "m.room.member", room_id: "!CCC:example.org", content: { membership: "ban" }, state_key: "@_prefix_test:example.org", }, { type: "m.room.member", room_id: "!DDD:example.org", content: { membership: "invite" }, state_key: "@_prefix_test:example.org", }, { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "join" }, state_key: "@INVALID_USER:example.org", }, { type: "m.room.member", room_id: "!BBB:example.org", content: { membership: "leave" }, state_key: "@INVALID_USER:example.org", }, { type: "m.room.member", room_id: "!CCC:example.org", content: { membership: "ban" }, state_key: "@INVALID_USER:example.org", }, { type: "m.room.member", room_id: "!DDD:example.org", content: { membership: "invite" }, state_key: "@INVALID_USER:example.org", }, ], }; const joinSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(txnBody.events[0].room_id); expect(ev).toMatchObject(txnBody.events[0]); }); const leaveSpy = simple.stub().callFn((rid, ev) => { if (ev["content"]["membership"] === "leave") { expect(rid).toEqual(txnBody.events[1].room_id); expect(ev).toMatchObject(txnBody.events[1]); } else { expect(rid).toEqual(txnBody.events[2].room_id); expect(ev).toMatchObject(txnBody.events[2]); } }); const inviteSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(txnBody.events[3].room_id); expect(ev).toMatchObject(txnBody.events[3]); }); appservice.on("room.join", joinSpy); appservice.on("room.leave", leaveSpy); appservice.on("room.invite", inviteSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(joinSpy.callCount).toBe(1); expect(leaveSpy.callCount).toBe(2); expect(inviteSpy.callCount).toBe(1); joinSpy.callCount = 0; leaveSpy.callCount = 0; inviteSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should refresh membership information of intents when actions are performed against them', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const intent = appservice.getIntentForSuffix("test"); const refreshSpy = simple.stub().callFn(() => Promise.resolve([])); intent.refreshJoinedRooms = refreshSpy; // polyfill the dummy user too const intent2 = appservice.getIntentForSuffix("test___WRONGUSER"); intent2.refreshJoinedRooms = () => Promise.resolve([]); const joinTxn = { events: [ { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "join" }, state_key: "@_prefix_test:example.org", sender: "@_prefix_test:example.org", }, { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "join" }, state_key: "@_prefix_test___WRONGUSER:example.org", sender: "@_prefix_test:example.org", }, ], }; const kickTxn = { events: [ { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "leave" }, state_key: "@_prefix_test:example.org", sender: "@someone_else:example.org", }, { type: "m.room.member", room_id: "!AAA:example.org", content: { membership: "leave" }, state_key: "@_prefix_test___WRONGUSER:example.org", sender: "@someone_else:example.org", }, ], }; async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(refreshSpy.callCount).toBe(1); refreshSpy.callCount = 0; } await doCall("/transactions/1", { json: joinTxn }); await doCall("/_matrix/app/v1/transactions/2", { json: kickTxn }); } finally { appservice.stop(); } }); it('should handle room upgrade events in transactions', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const txnBody = { events: [ { type: "m.room.tombstone", content: { body: "hello world 1" }, state_key: "", room_id: "!a:example.org", }, { type: "m.room.create", content: { predecessor: { room_id: "!old:example.org" } }, state_key: "", room_id: "!b:example.org", }, ], }; const archiveSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(txnBody.events[0].room_id); expect(ev).toMatchObject(txnBody.events[0]); }); const upgradeSpy = simple.stub().callFn((rid, ev) => { expect(rid).toEqual(txnBody.events[1].room_id); expect(ev).toMatchObject(txnBody.events[1]); }); const eventSpy = simple.stub().callFn((rid, ev) => { if (ev['type'] === 'm.room.tombstone') { expect(rid).toEqual(txnBody.events[0].room_id); expect(ev).toMatchObject(txnBody.events[0]); } else { expect(rid).toEqual(txnBody.events[1].room_id); expect(ev).toMatchObject(txnBody.events[1]); } }); appservice.on("room.archived", archiveSpy); appservice.on("room.upgraded", upgradeSpy); appservice.on("room.event", eventSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "PUT", qs: { access_token: hsToken }, ...opts, }); expect(res).toMatchObject({}); expect(archiveSpy.callCount).toBe(1); expect(upgradeSpy.callCount).toBe(1); expect(eventSpy.callCount).toBe(2); archiveSpy.callCount = 0; upgradeSpy.callCount = 0; eventSpy.callCount = 0; } await doCall("/transactions/1", { json: txnBody }); await doCall("/_matrix/app/v1/transactions/2", { json: txnBody }); } finally { appservice.stop(); } }); it('should allow custom endpoints to be added to the express instance', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.expressAppInstance.get("/test", (_, res) => res.sendStatus(200)); await appservice.begin(); try { const res = await requestPromise({ uri: `http://localhost:${port}/test`, method: "GET", }); expect(res).toEqual("OK"); } finally { appservice.stop(); } }); // TODO: Populate once intent tests are stable it.skip('should not try decryption if crypto is not possible', async () => { }); it.skip('should decrypt events when enabled', async () => { }); it.skip('should decrypt using non-bot accounts if needed', async () => { }); it.skip('should create intents which are crypto-ready when enabled', async () => { }); it.skip('should not create crypto-ready intents when disabled', async () => { }); it.skip('should prepare the bot intent with encryption at startup if enabled', async () => { }); it('should emit during MSC3983 key claim requests', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const query = { "@alice:example.org": { "DEVICEID": ["signed_curve25519"], }, }; const response = { "@alice:example.org": { "DEVICEID": { "signed_curve25519:AAAAHg": { "key": "...", "signatures": { "@alice:example.org": { "ed25519:DEVICEID": "...", }, }, }, }, }, }; const claimSpy = simple.stub().callFn((q, fn) => { expect(q).toStrictEqual(query); fn(response); }); appservice.on("query.key_claim", claimSpy); const res = await requestPromise({ uri: `http://localhost:${port}/_matrix/app/unstable/org.matrix.msc3983/keys/claim`, method: "POST", qs: { access_token: hsToken }, json: query, }); expect(res).toStrictEqual(response); expect(claimSpy.callCount).toBe(1); } finally { appservice.stop(); } }); it('should return a 404 for MSC3983 if not used by consumer', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const query = { "@alice:example.org": { "DEVICEID": ["signed_curve25519"], }, }; // Note how we're not registering anything with the EventEmitter const res = await requestPromise({ uri: `http://localhost:${port}/_matrix/app/unstable/org.matrix.msc3983/keys/claim`, method: "POST", qs: { access_token: hsToken }, json: query, }).catch(e => ({ body: e.response.body, statusCode: e.statusCode })); expect(res).toStrictEqual({ statusCode: 404, body: { errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" } }); } finally { appservice.stop(); } }); it('should emit during MSC3983 key claim requests', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const query = { "@alice:example.org": ["DEVICE_ID"], "@bob:example.org": [], }; const response = { "@alice:example.org": { "DEVICE_ID": { "schema_not_used": true, }, }, "@bob:example.org": { "DEVICE_ID": { "schema_not_used": true, }, }, }; const querySpy = simple.stub().callFn((q, fn) => { expect(q).toStrictEqual(query); fn(response); }); appservice.on("query.key", querySpy); const res = await requestPromise({ uri: `http://localhost:${port}/_matrix/app/unstable/org.matrix.msc3984/keys/query`, method: "POST", qs: { access_token: hsToken }, json: query, }); expect(res).toStrictEqual(response); expect(querySpy.callCount).toBe(1); } finally { appservice.stop(); } }); it('should return a 404 for MSC3984 if not used by consumer', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const query = { "@alice:example.org": ["DEVICE_ID"], "@bob:example.org": [], }; // Note how we're not registering anything with the EventEmitter const res = await requestPromise({ uri: `http://localhost:${port}/_matrix/app/unstable/org.matrix.msc3984/keys/query`, method: "POST", qs: { access_token: hsToken }, json: query, }).catch(e => ({ body: e.response.body, statusCode: e.statusCode })); expect(res).toStrictEqual({ statusCode: 404, body: { errcode: "M_UNRECOGNIZED", error: "Endpoint not implemented" } }); } finally { appservice.stop(); } }); it('should emit while querying users', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const userId = "@_prefix_test:example.org"; // Prepare an intent for the user now const userIntent = appservice.getIntentForUserId(userId); userIntent.ensureRegistered = () => { return null; }; const nameSpy = simple.mock(userIntent.underlyingClient, "setDisplayName").callFn(() => { return null; }); const avatarSpy = simple.mock(userIntent.underlyingClient, "setAvatarUrl").callFn(() => { return null; }); const userSpy = simple.stub().callFn((uid, fn) => { expect(uid).toEqual(userId); fn({}); }); appservice.on("query.user", userSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); expect(res).toMatchObject({}); expect(nameSpy.callCount).toBe(0); expect(avatarSpy.callCount).toBe(0); expect(userSpy.callCount).toBe(1); nameSpy.callCount = 0; avatarSpy.callCount = 0; userSpy.callCount = 0; } await doCall("/users/" + userId); await doCall("/_matrix/app/v1/users/" + userId); } finally { appservice.stop(); } }); it('should handle profiles while querying users', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const hsUrl = "https://localhost"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: hsUrl, registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const http = new HttpBackend(); setRequestFn(http.requestFn); await appservice.begin(); try { const userId = "@_prefix_test:example.org"; const displayName = "Display Name Here"; const avatarUrl = "mxc://example.org/testing"; // Prepare an intent for the user now const userIntent = appservice.getIntentForUserId(userId); userIntent.ensureRegistered = () => { return null; }; const userSpy = simple.stub().callFn((uid, fn) => { expect(uid).toEqual(userId); fn({ display_name: displayName, avatar_mxc: avatarUrl, }); }); appservice.on("query.user", userSpy); async function doCall(route: string, opts: any = {}) { http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/displayname`); expect(content).toMatchObject({ displayname: displayName }); return {}; }); http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/avatar_url`); expect(content).toMatchObject({ avatar_url: avatarUrl }); return {}; }); const [res] = await Promise.all([requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }), http.flushAllExpected()]); expect(res).toMatchObject({}); expect(userSpy.callCount).toBe(1); userSpy.callCount = 0; } await doCall("/users/" + userId); await doCall("/_matrix/app/v1/users/" + userId); } finally { appservice.stop(); } }); it('should handle promises for profiles while querying users', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const hsUrl = "https://localhost"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: hsUrl, registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const http = new HttpBackend(); setRequestFn(http.requestFn); await appservice.begin(); try { const userId = "@_prefix_test:example.org"; const displayName = "Display Name Here"; const avatarUrl = "mxc://example.org/testing"; // Prepare an intent for the user now const userIntent = appservice.getIntentForUserId(userId); userIntent.ensureRegistered = () => { return null; }; const userSpy = simple.stub().callFn((uid, fn) => { expect(uid).toEqual(userId); fn(Promise.resolve({ display_name: displayName, avatar_mxc: avatarUrl, })); }); appservice.on("query.user", userSpy); async function doCall(route: string, opts: any = {}) { http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/displayname`); expect(content).toMatchObject({ displayname: displayName }); return {}; }); http.when("PUT", "/_matrix/client/v3/profile").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/profile/${encodeURIComponent(userId)}/avatar_url`); expect(content).toMatchObject({ avatar_url: avatarUrl }); return {}; }); const [res] = await Promise.all([requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }), http.flushAllExpected()]); expect(res).toMatchObject({}); expect(userSpy.callCount).toBe(1); userSpy.callCount = 0; } await doCall("/users/" + userId); await doCall("/_matrix/app/v1/users/" + userId); } finally { appservice.stop(); } }); it('should return user not found when a user is not created', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const userId = "@_prefix_test:example.org"; // Prepare an intent for the user now const userIntent = appservice.getIntentForUserId(userId); userIntent.ensureRegistered = () => { return null; }; const nameSpy = simple.mock(userIntent.underlyingClient, "setDisplayName").callFn(() => { return null; }); const avatarSpy = simple.mock(userIntent.underlyingClient, "setAvatarUrl").callFn(() => { return null; }); const userSpy = simple.stub().callFn((uid, fn) => { expect(uid).toEqual(userId); fn(false); }); appservice.on("query.user", userSpy); async function doCall(route: string, opts: any = {}) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "USER_DOES_NOT_EXIST", error: "User not created", }); expect(e.statusCode).toBe(404); } expect(nameSpy.callCount).toBe(0); expect(avatarSpy.callCount).toBe(0); expect(userSpy.callCount).toBe(1); nameSpy.callCount = 0; avatarSpy.callCount = 0; userSpy.callCount = 0; } await doCall("/users/" + encodeURIComponent(userId)); await doCall("/_matrix/app/v1/users/" + encodeURIComponent(userId)); } finally { appservice.stop(); } }); it('should return user not found when a promise to not create a user is seen', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const userId = "@_prefix_test:example.org"; // Prepare an intent for the user now const userIntent = appservice.getIntentForUserId(userId); userIntent.ensureRegistered = () => { return null; }; const nameSpy = simple.mock(userIntent.underlyingClient, "setDisplayName").callFn(() => { return null; }); const avatarSpy = simple.mock(userIntent.underlyingClient, "setAvatarUrl").callFn(() => { return null; }); const userSpy = simple.stub().callFn((uid, fn) => { expect(uid).toEqual(userId); fn(Promise.resolve(false)); }); appservice.on("query.user", userSpy); async function doCall(route: string, opts: any = {}) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "USER_DOES_NOT_EXIST", error: "User not created", }); expect(e.statusCode).toBe(404); } expect(nameSpy.callCount).toBe(0); expect(avatarSpy.callCount).toBe(0); expect(userSpy.callCount).toBe(1); nameSpy.callCount = 0; avatarSpy.callCount = 0; userSpy.callCount = 0; } await doCall("/users/" + encodeURIComponent(userId)); await doCall("/_matrix/app/v1/users/" + encodeURIComponent(userId)); } finally { appservice.stop(); } }); it('should emit while querying rooms', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const roomOptions = {}; const roomAlias = "#_prefix_test:example.org"; const roomId = "!something:example.org"; const expected = Object.assign({}, roomOptions, { __roomId: roomId, room_alias_name: roomAlias.substring(1).split(':')[0], }); const createRoomSpy = simple.mock(appservice.botIntent.underlyingClient, "createRoom").callFn((opts) => { expect(opts).toMatchObject(roomOptions); return Promise.resolve(roomId); }); const roomSpy = simple.stub().callFn((ralias, fn) => { expect(ralias).toEqual(roomAlias); fn(roomOptions); }); appservice.on("query.room", roomSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); expect(res).toMatchObject(expected); expect(createRoomSpy.callCount).toBe(1); expect(roomSpy.callCount).toBe(1); createRoomSpy.callCount = 0; roomSpy.callCount = 0; } await doCall("/rooms/" + encodeURIComponent(roomAlias)); await doCall("/_matrix/app/v1/rooms/" + encodeURIComponent(roomAlias)); } finally { appservice.stop(); } }); it('should handle options while querying rooms', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const roomOptions = { preset: "public_chat" }; const roomAlias = "#_prefix_test:example.org"; const roomId = "!something:example.org"; const expected = Object.assign({}, roomOptions, { __roomId: roomId, room_alias_name: roomAlias.substring(1).split(':')[0], }); const createRoomSpy = simple.mock(appservice.botIntent.underlyingClient, "createRoom").callFn((opts) => { expect(opts).toMatchObject(roomOptions); return Promise.resolve(roomId); }); const roomSpy = simple.stub().callFn((ralias, fn) => { expect(ralias).toEqual(roomAlias); fn(roomOptions); }); appservice.on("query.room", roomSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); expect(res).toMatchObject(expected); expect(createRoomSpy.callCount).toBe(1); expect(roomSpy.callCount).toBe(1); createRoomSpy.callCount = 0; roomSpy.callCount = 0; } await doCall("/rooms/" + encodeURIComponent(roomAlias)); await doCall("/_matrix/app/v1/rooms/" + encodeURIComponent(roomAlias)); } finally { appservice.stop(); } }); it('should handle promises for options while querying rooms', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const roomOptions = { preset: "public_chat" }; const roomAlias = "#_prefix_test:example.org"; const roomId = "!something:example.org"; const expected = Object.assign({}, roomOptions, { __roomId: roomId, room_alias_name: roomAlias.substring(1).split(':')[0], }); const createRoomSpy = simple.mock(appservice.botIntent.underlyingClient, "createRoom").callFn((opts) => { expect(opts).toMatchObject(roomOptions); return Promise.resolve(roomId); }); const roomSpy = simple.stub().callFn((ralias, fn) => { expect(ralias).toEqual(roomAlias); fn(Promise.resolve(roomOptions)); }); appservice.on("query.room", roomSpy); async function doCall(route: string, opts: any = {}) { const res = await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); expect(res).toMatchObject(expected); expect(createRoomSpy.callCount).toBe(1); expect(roomSpy.callCount).toBe(1); createRoomSpy.callCount = 0; roomSpy.callCount = 0; } await doCall("/rooms/" + encodeURIComponent(roomAlias)); await doCall("/_matrix/app/v1/rooms/" + encodeURIComponent(roomAlias)); } finally { appservice.stop(); } }); it('should return room not found when a room is not created', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const roomOptions = { preset: "public_chat" }; const roomAlias = "#_prefix_test:example.org"; const roomId = "!something:example.org"; const createRoomSpy = simple.mock(appservice.botIntent.underlyingClient, "createRoom").callFn((opts) => { expect(opts).toMatchObject(roomOptions); return Promise.resolve(roomId); }); const roomSpy = simple.stub().callFn((ralias, fn) => { expect(ralias).toEqual(roomAlias); fn(false); }); appservice.on("query.room", roomSpy); async function doCall(route: string, opts: any = {}) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "ROOM_DOES_NOT_EXIST", error: "Room not created", }); expect(e.statusCode).toBe(404); } expect(createRoomSpy.callCount).toBe(0); expect(roomSpy.callCount).toBe(1); createRoomSpy.callCount = 0; roomSpy.callCount = 0; } await doCall("/rooms/" + encodeURIComponent(roomAlias)); await doCall("/_matrix/app/v1/rooms/" + encodeURIComponent(roomAlias)); } finally { appservice.stop(); } }); it('should return room not found when a promise to not create a room is seen', async () => { const port = await getPort(); const hsToken = "s3cret_token"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; await appservice.begin(); try { const roomOptions = { preset: "public_chat" }; const roomAlias = "#_prefix_test:example.org"; const roomId = "!something:example.org"; const createRoomSpy = simple.mock(appservice.botIntent.underlyingClient, "createRoom").callFn((opts) => { expect(opts).toMatchObject(roomOptions); return Promise.resolve(roomId); }); const roomSpy = simple.stub().callFn((ralias, fn) => { expect(ralias).toEqual(roomAlias); fn(Promise.resolve(false)); }); appservice.on("query.room", roomSpy); async function doCall(route: string, opts: any = {}) { try { await requestPromise({ uri: `http://localhost:${port}${route}`, method: "GET", qs: { access_token: hsToken }, json: true, ...opts, }); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "ROOM_DOES_NOT_EXIST", error: "Room not created", }); expect(e.statusCode).toBe(404); } expect(createRoomSpy.callCount).toBe(0); expect(roomSpy.callCount).toBe(1); createRoomSpy.callCount = 0; roomSpy.callCount = 0; } await doCall("/rooms/" + encodeURIComponent(roomAlias)); await doCall("/_matrix/app/v1/rooms/" + encodeURIComponent(roomAlias)); } finally { appservice.stop(); } }); it("should handle third party protocol requests", async () => { const protos = ["fakeproto", "anotherproto"]; const { appservice, doCall } = await beginAppserviceWithProtocols(protos); const responseObj = { notarealresponse: true }; const getProtoSpy = simple.stub().callFn((protocol, fn) => { expect(protos).toContain(protocol); fn(responseObj); }); try { appservice.on("thirdparty.protocol", getProtoSpy); const result = await doCall("/_matrix/app/v1/thirdparty/protocol/" + protos[0]); expect(result).toEqual(responseObj); const result2 = await doCall("/_matrix/app/v1/thirdparty/protocol/" + protos[1]); expect(result2).toEqual(responseObj); } finally { appservice.stop(); } }); it("should reject unknown protocols when handling third party protocol requests", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); const expectedError = { errcode: "PROTOCOL_NOT_HANDLED", error: "Protocol is not handled by this appservice", }; const expectedStatus = 404; try { await doCall("/_matrix/app/v1/thirdparty/protocol/notaproto"); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject(expectedError); expect(e.statusCode).toBe(expectedStatus); } finally { appservice.stop(); } }); it("should lookup a remote user by given fields and respond with it", async () => { const protocolId = "fakeproto"; const { appservice, doCall } = await beginAppserviceWithProtocols([protocolId]); const responseObj = ["user1", "user2"]; const userFields = { "foo": "bar", "bar": "baz", }; const getUserSpy = simple.stub().callFn((protocol, fields, fn) => { expect(protocol).toEqual(protocolId); expect(fields).toEqual(userFields); fn(responseObj); }); appservice.on("thirdparty.user.remote", getUserSpy); try { const result = await doCall("/_matrix/app/v1/thirdparty/user/" + protocolId, {}, userFields); expect(result).toEqual(responseObj); } finally { appservice.stop(); } }); it("should lookup a matrix user by given fields and respond with it", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); const responseObj = ["user1", "user2"]; const expectedUserId = "@foobar:localhost"; const getUserSpy = simple.stub().callFn((userid, fn) => { expect(userid).toEqual(expectedUserId); fn(responseObj); }); appservice.on("thirdparty.user.matrix", getUserSpy); try { const result = await doCall("/_matrix/app/v1/thirdparty/user", {}, { userid: expectedUserId }); expect(result).toEqual(responseObj); } finally { appservice.stop(); } }); it("should fail to lookup a remote user if the protocol is wrong", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); try { await doCall("/_matrix/app/v1/thirdparty/user/pr0tocol"); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "PROTOCOL_NOT_HANDLED", error: "Protocol is not handled by this appservice", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should return 404 if no matrix users are found when handling a third party user request", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); const expectedUserId = "@foobar:localhost"; const getUserSpy = simple.stub().callFn((userid, fn) => { expect(userid).toEqual(expectedUserId); fn([]); }); appservice.on("thirdparty.user.matrix", getUserSpy); try { await doCall("/_matrix/app/v1/thirdparty/user", {}, { userid: expectedUserId }); } catch (e) { expect(e.error).toMatchObject({ errcode: "NO_MAPPING_FOUND", error: "No mappings found", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should return 404 if no remote users are found when handling a thirdparty user request", async () => { const protocolId = "fakeproto"; const { appservice, doCall } = await beginAppserviceWithProtocols([protocolId]); const userFields = { "foo": "bar", "bar": "baz", }; const getUserSpy = simple.stub().callFn((proto, fields, fn) => { expect(proto).toEqual(protocolId); expect(fields).toEqual(userFields); fn([]); }); appservice.on("thirdparty.user.remote", getUserSpy); try { await doCall("/_matrix/app/v1/thirdparty/user/" + protocolId, {}, userFields); } catch (e) { expect(e.error).toMatchObject({ errcode: "NO_MAPPING_FOUND", error: "No mappings found", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should fail to lookup a remote user if the mxid is empty", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); try { await doCall("/_matrix/app/v1/thirdparty/user"); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "INVALID_PARAMETERS", error: "Invalid parameters given", }); expect(e.statusCode).toBe(400); } finally { appservice.stop(); } }); it("should lookup a remote location by given fields", async () => { const protocolId = "fakeproto"; const { appservice, doCall } = await beginAppserviceWithProtocols([protocolId]); const responseObj = ["loc1", "loc2"]; const locationFields = { "foo": "bar", "bar": "baz", }; const getLocationSpy = simple.stub().callFn((protocol, fields, fn) => { expect(protocol).toEqual(protocolId); expect(fields).toEqual(locationFields); fn(responseObj); }); appservice.on("thirdparty.location.remote", getLocationSpy); try { const result = await doCall("/_matrix/app/v1/thirdparty/location/" + protocolId, {}, locationFields); expect(result).toEqual(responseObj); } finally { appservice.stop(); } }); it("should lookup a matrix location by given fields", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); const responseObj = ["loc1", "loc2"]; const expectedAlias = "#alias:localhost"; const getLocationSpy = simple.stub().callFn((alias, fn) => { expect(alias).toEqual(expectedAlias); fn(responseObj); }); appservice.on("thirdparty.location.matrix", getLocationSpy); try { const result = await doCall("/_matrix/app/v1/thirdparty/location", {}, { alias: expectedAlias }); expect(result).toEqual(responseObj); } finally { appservice.stop(); } }); it("should fail to lookup a remote location if the protocol is wrong", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); try { await doCall("/_matrix/app/v1/thirdparty/location/pr0tocol"); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "PROTOCOL_NOT_HANDLED", error: "Protocol is not handled by this appservice", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should return 404 if no matrix locations are found", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); const expectedAlias = "#alias:localhost"; const getUserSpy = simple.stub().callFn((alias, fn) => { expect(alias).toEqual(expectedAlias); fn([]); }); appservice.on("thirdparty.location.matrix", getUserSpy); try { await doCall("/_matrix/app/v1/thirdparty/location", {}, { alias: expectedAlias }); } catch (e) { expect(e.error).toMatchObject({ errcode: "NO_MAPPING_FOUND", error: "No mappings found", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should return 404 if no remote location are found", async () => { const protocolId = "fakeproto"; const { appservice, doCall } = await beginAppserviceWithProtocols([protocolId]); const locationFields = { "foo": "bar", "bar": "baz", }; const getLocationSpy = simple.stub().callFn((proto, fields, fn) => { expect(proto).toEqual("fakeproto"); expect(fields).toEqual(locationFields); fn([]); }); appservice.on("thirdparty.location.remote", getLocationSpy); try { await doCall("/_matrix/app/v1/thirdparty/location/" + protocolId, {}, locationFields); } catch (e) { expect(e.error).toMatchObject({ errcode: "NO_MAPPING_FOUND", error: "No mappings found", }); expect(e.statusCode).toBe(404); } finally { appservice.stop(); } }); it("should fail to lookup a matrix location if the alias is empty", async () => { const { appservice, doCall } = await beginAppserviceWithProtocols(["fakeproto"]); try { await doCall("/_matrix/app/v1/thirdparty/location"); // noinspection ExceptionCaughtLocallyJS throw new Error("Request finished when it should not have"); } catch (e) { expect(e.error).toMatchObject({ errcode: "INVALID_PARAMETERS", error: "Invalid parameters given", }); expect(e.statusCode).toBe(400); } finally { appservice.stop(); } }); it("should set visibilty of a room on the appservice's network", async () => { const port = await getPort(); const hsToken = "s3cret_token"; const hsUrl = "https://localhost"; const networkId = "foonetwork"; const roomId = "!aroomid:example.org"; const appservice = new Appservice({ port: port, bindAddress: '', homeserverName: 'example.org', homeserverUrl: hsUrl, registration: { as_token: "", hs_token: hsToken, sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const http = new HttpBackend(); setRequestFn(http.requestFn); http.when("PUT", "/_matrix/client/v3/directory/list/appservice").respond(200, (path, content) => { expect(path).toEqual(`${hsUrl}/_matrix/client/v3/directory/list/appservice/${encodeURIComponent(networkId)}/${encodeURIComponent(roomId)}`); expect(content).toMatchObject({ visibility: "public" }); return {}; }); await Promise.all([ appservice.setRoomDirectoryVisibility("foonetwork", "!aroomid:example.org", "public"), http.flushAllExpected(), ]); }); }); ================================================ FILE: test/appservice/IntentTest.ts ================================================ import * as simple from "simple-mock"; import HttpBackend from 'matrix-mock-request'; import * as tmp from "tmp"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { expectArrayEquals } from "../TestUtils"; import { Appservice, IAppserviceCryptoStorageProvider, IAppserviceOptions, IAppserviceStorageProvider, IJoinRoomStrategy, Intent, MemoryStorageProvider, RustSdkAppserviceCryptoStorageProvider, setRequestFn, } from "../../src"; tmp.setGracefulCleanup(); describe('Intent', () => { it('should prepare the underlying client for a bot user', async () => { const userId = "@someone:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); expect(intent.userId).toEqual(userId); expect(intent.underlyingClient).toBeDefined(); expect((intent.underlyingClient).impersonatedUserId).toBeUndefined(); expect((intent.underlyingClient).accessToken).toEqual(asToken); expect((intent.underlyingClient).homeserverUrl).toEqual(hsUrl); }); it('should prepare the underlying client for a bot user with a join strategy', async () => { const userId = "@someone:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const joinStrategy = {}; const options = { homeserverUrl: hsUrl, joinStrategy: joinStrategy, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); expect(intent.userId).toEqual(userId); expect(intent.underlyingClient).toBeDefined(); expect((intent.underlyingClient).impersonatedUserId).toBeUndefined(); expect((intent.underlyingClient).accessToken).toEqual(asToken); expect((intent.underlyingClient).homeserverUrl).toEqual(hsUrl); expect((intent.underlyingClient).joinStrategy).toEqual(joinStrategy); }); it('should prepare the underlying client for an impersonated user', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); expect(intent.userId).toEqual(userId); expect(intent.underlyingClient).toBeDefined(); expect((intent.underlyingClient).impersonatedUserId).toEqual(userId); expect((intent.underlyingClient).accessToken).toEqual(asToken); expect((intent.underlyingClient).homeserverUrl).toEqual(hsUrl); }); it('should prepare the underlying client for an impersonated user with a join strategy', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const joinStrategy = {}; const options = { homeserverUrl: hsUrl, joinStrategy: joinStrategy, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); expect(intent.userId).toEqual(userId); expect(intent.underlyingClient).toBeDefined(); expect((intent.underlyingClient).impersonatedUserId).toEqual(userId); expect((intent.underlyingClient).accessToken).toEqual(asToken); expect((intent.underlyingClient).homeserverUrl).toEqual(hsUrl); expect((intent.underlyingClient).joinStrategy).toEqual(joinStrategy); }); describe('ensureRegistered', () => { it('should do nothing if the user is flagged as registered', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); await intent.ensureRegistered(); expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(0); }); it('should try to register the user when not flagged as such', async () => { const http = new HttpBackend(); setRequestFn(http.requestFn); const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return false; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ type: "m.login.application_service", username: "someone" }); return {}; }); const intent = new Intent(options, userId, appservice); await Promise.all([intent.ensureRegistered(), http.flushAllExpected()]); expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(1); }); it('should use the provided device ID', async () => { const http = new HttpBackend(); setRequestFn(http.requestFn); const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const deviceId = "DEVICE_TEST"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return false; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ type: "m.login.application_service", username: "someone", device_id: deviceId }); return { device_id: deviceId }; }); const intent = new Intent(options, userId, appservice); await Promise.all([intent.ensureRegistered(deviceId), http.flushAllExpected()]); // ensureRegistered(deviceId) expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(1); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams["user_id"]).toBe(userId); expect(req.queryParams["org.matrix.msc3202.device_id"]).toBe(deviceId); }); await Promise.all([intent.underlyingClient.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should impersonate the returned device ID on register', async () => { const http = new HttpBackend(); setRequestFn(http.requestFn); const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const deviceId = "DEVICE_TEST"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return false; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ type: "m.login.application_service", username: "someone" }); return { device_id: deviceId }; }); const intent = new Intent(options, userId, appservice); await Promise.all([intent.ensureRegistered(), http.flushAllExpected()]); expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(1); // noinspection TypeScriptValidateJSTypes http.when("GET", "/test").respond(200, (path, content, req) => { expect(req.queryParams["user_id"]).toBe(userId); expect(req.queryParams["org.matrix.msc3202.device_id"]).toBe(deviceId); }); await Promise.all([intent.underlyingClient.doRequest("GET", "/test"), http.flushAllExpected()]); }); it('should gracefully handle M_USER_IN_USE', async () => { const http = new HttpBackend(); setRequestFn(http.requestFn); const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return false; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; // HACK: 200 OK because the mock lib can't handle 400+response body http.when("POST", "/_matrix/client/v3/register").respond(200, (path, content) => { expect(content).toMatchObject({ type: "m.login.application_service", username: "someone" }); return { errcode: "M_USER_IN_USE", error: "User ID already in use" }; }); const intent = new Intent(options, userId, appservice); await Promise.all([intent.ensureRegistered(), http.flushAllExpected()]); expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(1); }); it('should handle unexpected errors', async () => { const http = new HttpBackend(); setRequestFn(http.requestFn); const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const isRegisteredSpy = simple.mock(storage, "isUserRegistered").callFn((uid) => { expect(uid).toEqual(userId); return false; }); const addRegisteredSpy = simple.mock(storage, "addRegisteredUser").callFn((uid) => { expect(uid).toEqual(userId); return true; }); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; http.when("POST", "/_matrix/client/v3/register").respond(500, (path, content) => { expect(content).toMatchObject({ type: "m.login.application_service", username: "someone" }); return { errcode: "M_UNKNOWN", error: "It broke" }; }); const flush = http.flushAllExpected(); const intent = new Intent(options, userId, appservice); try { await intent.ensureRegistered(); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.statusCode).toBe(500); } expect(isRegisteredSpy.callCount).toBe(1); expect(addRegisteredSpy.callCount).toBe(0); await flush; }); }); describe('getJoinedRooms', () => { it('should fetch rooms if none are cached', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomsPartA = ['!a:example.org', '!b:example.org']; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const getJoinedSpy = simple.stub().callFn(() => { return Promise.resolve(roomsPartA); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; const joinedRooms = await intent.getJoinedRooms(); expectArrayEquals(roomsPartA, joinedRooms); expect(registeredSpy.callCount).toBe(1); expect(getJoinedSpy.callCount).toBe(1); }); it('should cache rooms on join', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomId = "!test:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const getJoinedSpy = simple.stub().callFn(() => { if (getJoinedSpy.callCount === 1) return Promise.resolve([]); return Promise.resolve([roomId]); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; const joinSpy = simple.stub().callFn((rid) => { expect(rid).toBe(roomId); return Promise.resolve(rid); }); intent.underlyingClient.joinRoom = joinSpy; let joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([], joinedRooms); expect(registeredSpy.callCount).toBe(1); expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); await intent.joinRoom(roomId); joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([roomId], joinedRooms); expect(registeredSpy.callCount).toBe(3); expect(getJoinedSpy.callCount).toBe(2); expect(joinSpy.callCount).toBe(1); }); it('should cache rooms on leave', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomId = "!test:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const getJoinedSpy = simple.stub().callFn(() => { if (getJoinedSpy.callCount > 1) return Promise.resolve([]); return Promise.resolve([roomId]); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; const leaveSpy = simple.stub().callFn((rid) => { expect(rid).toBe(roomId); return Promise.resolve(rid); }); intent.underlyingClient.leaveRoom = leaveSpy; let joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([roomId], joinedRooms); expect(registeredSpy.callCount).toBe(1); expect(getJoinedSpy.callCount).toBe(1); expect(leaveSpy.callCount).toBe(0); await intent.leaveRoom(roomId); joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([], joinedRooms); expect(registeredSpy.callCount).toBe(3); expect(getJoinedSpy.callCount).toBe(3); expect(leaveSpy.callCount).toBe(1); }); it('should cache rooms on ensureJoined', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomId = "!test:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const getJoinedSpy = simple.stub().callFn(() => { if (getJoinedSpy.callCount <= 2) return Promise.resolve([]); return Promise.resolve([roomId]); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; const joinSpy = simple.stub().callFn((rid) => { expect(rid).toBe(roomId); return Promise.resolve(rid); }); intent.underlyingClient.joinRoom = joinSpy; let joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([], joinedRooms); expect(registeredSpy.callCount).toBe(1); expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); await intent.ensureJoined(roomId); joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([roomId], joinedRooms); expect(registeredSpy.callCount).toBe(2); expect(getJoinedSpy.callCount).toBe(2); expect(joinSpy.callCount).toBe(1); // Duplicate just to prove it caches it await intent.ensureJoined(roomId); joinedRooms = await intent.getJoinedRooms(); expectArrayEquals([roomId], joinedRooms); expect(registeredSpy.callCount).toBe(3); expect(getJoinedSpy.callCount).toBe(2); expect(joinSpy.callCount).toBe(1); }); }); describe('refreshJoinedRooms', () => { it('should overwrite any previously known joined rooms', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomsPartA = ['!a:example.org', '!b:example.org']; const roomsPartB = ['!c:example.org', '!d:example.org']; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); // We have to do private access to ensure that the intent actually overwrites // its cache. const getJoinedRooms = () => (intent).knownJoinedRooms; const setJoinedRooms = (rooms) => (intent).knownJoinedRooms = rooms; const getJoinedSpy = simple.stub().callFn(() => { return Promise.resolve(roomsPartB); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; // Do a quick assert to prove that our private access hooks work expectArrayEquals([], getJoinedRooms()); setJoinedRooms(roomsPartA); expectArrayEquals(roomsPartA, getJoinedRooms()); const result = await intent.refreshJoinedRooms(); expect(getJoinedSpy.callCount).toBe(1); expectArrayEquals(roomsPartB, result); expectArrayEquals(roomsPartB, getJoinedRooms()); }); }); describe('ensureJoined', () => { it('should fetch the rooms the user is joined to', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomIds = ["!a:example.org", "!b:example.org"]; const targetRoomId = "!a:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const getJoinedSpy = simple.stub().callFn(() => { return Promise.resolve(roomIds); }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve("!joined:example.org"); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; await intent.ensureJoined(targetRoomId); expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); }); it('should attempt to join rooms a user is not in', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomIds = ["!a:example.org", "!b:example.org"]; const targetRoomId = "!c:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const getJoinedSpy = simple.stub().callFn(() => { return Promise.resolve(roomIds); }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve("!joined:example.org"); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; await intent.ensureJoined(targetRoomId); expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); it('should proxy failure for joining a room', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const roomIds = ["!a:example.org", "!b:example.org"]; const targetRoomId = "!c:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const getJoinedSpy = simple.stub().callFn(() => { return Promise.resolve(roomIds); }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); throw new Error("Simulated failure"); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; try { await intent.ensureJoined(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); it('should proxy failure for getting joined rooms', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const targetRoomId = "!c:example.org"; const appservice = { botUserId: botUserId }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const getJoinedSpy = simple.stub().callFn(() => { throw new Error("Simulated failure"); }); const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve("!joined:example.org"); }); intent.underlyingClient.getJoinedRooms = getJoinedSpy; intent.underlyingClient.joinRoom = joinSpy; try { await intent.ensureJoined(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(getJoinedSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); }); }); describe('ensureRegisteredAndJoined', () => { it('should call both ensureRegistered and ensureJoined', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); await intent.ensureRegisteredAndJoined(targetRoomId); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); it('should proxy failure from ensureRegistered', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { throw new Error("Simulated failure"); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); try { await intent.ensureRegisteredAndJoined(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); }); it('should proxy failure from ensureJoined', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); throw new Error("Simulated failure"); }); try { await intent.ensureRegisteredAndJoined(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); }); describe('sendEvent', () => { it('should proxy through to the client while ensuring they are registered and joined', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const content = { hello: "world" }; const eventId = "$something:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const eventSpy = simple.stub().callFn((rid, c) => { expect(rid).toEqual(targetRoomId); expect(c).toMatchObject(content); return Promise.resolve(eventId); }); intent.underlyingClient.sendMessage = eventSpy; const result = await intent.sendEvent(targetRoomId, content); expect(result).toEqual(eventId); expect(eventSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const content = { hello: "world" }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const eventSpy = simple.stub().callFn((rid, c) => { expect(rid).toEqual(targetRoomId); expect(c).toMatchObject(content); throw new Error("Simulated failure"); }); intent.underlyingClient.sendMessage = eventSpy; try { await intent.sendEvent(targetRoomId, content); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(eventSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); }); describe('sendText', () => { it('should proxy through to the client while ensuring they are registered and joined', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const content = { body: "hello world", msgtype: "m.emote" }; const eventId = "$something:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const eventSpy = simple.stub().callFn((rid, c) => { expect(rid).toEqual(targetRoomId); expect(c).toMatchObject(content); return Promise.resolve(eventId); }); intent.underlyingClient.sendMessage = eventSpy; const result = await intent.sendText(targetRoomId, content.body, content.msgtype); expect(result).toEqual(eventId); expect(eventSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const content = { body: "hello world", msgtype: "m.emote" }; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const eventSpy = simple.stub().callFn((rid, c) => { expect(rid).toEqual(targetRoomId); expect(c).toMatchObject(content); throw new Error("Simulated failure"); }); intent.underlyingClient.sendMessage = eventSpy; try { await intent.sendText(targetRoomId, content.body, content.msgtype); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(eventSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(1); }); }); describe('joinRoom', () => { it('should proxy through to the client while ensuring they are registered', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const refreshJoinedRoomsSpy = simple.stub().callFn(() => { return Promise.resolve([]); }); const joinRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve(targetRoomId); }); intent.underlyingClient.joinRoom = joinRoomSpy; intent.refreshJoinedRooms = refreshJoinedRoomsSpy; const result = await intent.joinRoom(targetRoomId); expect(result).toEqual(targetRoomId); expect(joinRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); expect(refreshJoinedRoomsSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const joinRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); throw new Error("Simulated failure"); }); intent.underlyingClient.joinRoom = joinRoomSpy; try { await intent.joinRoom(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(joinRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); }); }); describe('leaveRoom', () => { it('should proxy through to the client while ensuring they are registered', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const refreshJoinedRoomsSpy = simple.stub().callFn(() => { return Promise.resolve([]); }); const leaveRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); return Promise.resolve(targetRoomId); }); intent.underlyingClient.leaveRoom = leaveRoomSpy; intent.refreshJoinedRooms = refreshJoinedRoomsSpy; await intent.leaveRoom(targetRoomId); expect(leaveRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); expect(refreshJoinedRoomsSpy.callCount).toBe(1); }); it('should proxy errors upwards', async () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; const targetRoomId = "!a:example.org"; const storage = new MemoryStorageProvider(); const options = { homeserverUrl: hsUrl, storage: storage, registration: { as_token: asToken, }, }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const joinSpy = simple.mock(intent, "ensureJoined").callFn((rid) => { expect(rid).toEqual(targetRoomId); return {}; }); const leaveRoomSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(targetRoomId); throw new Error("Simulated failure"); }); intent.underlyingClient.leaveRoom = leaveRoomSpy; try { await intent.leaveRoom(targetRoomId); // noinspection ExceptionCaughtLocallyJS throw new Error("Request completed when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(leaveRoomSpy.callCount).toBe(1); expect(registeredSpy.callCount).toBe(1); expect(joinSpy.callCount).toBe(0); }); }); describe('enableEncryption', () => { const userId = "@someone:example.org"; const botUserId = "@bot:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: botUserId }; let storage: IAppserviceStorageProvider; let cryptoStorage: IAppserviceCryptoStorageProvider; let options: IAppserviceOptions; let intent: Intent; beforeEach(() => { storage = new MemoryStorageProvider(); cryptoStorage = new RustSdkAppserviceCryptoStorageProvider(tmp.dirSync().name, StoreType.Sqlite); options = { homeserverUrl: hsUrl, storage: storage, cryptoStorage: cryptoStorage, intentOptions: { encryption: true, }, port: 9000, bindAddress: "127.0.0.1", homeserverName: "example.org", registration: { id: asToken, as_token: asToken, hs_token: asToken, sender_localpart: "bot", namespaces: { users: [], aliases: [], rooms: [], }, }, }; intent = new Intent(options, userId, appservice); }); // TODO: Test once device_id impersonation set up it.skip('should only set up crypto once', async () => { }); it.skip('should set up crypto', async () => { }); it.skip('should impersonate device IDs when known', async () => { }); it.skip('should use the same device ID when known', async () => { }); it.skip('should log in to get a device ID if none are viable', async () => { }); }); }); ================================================ FILE: test/appservice/MatrixBridgeTest.ts ================================================ import * as simple from "simple-mock"; import { Appservice, IAppserviceOptions, Intent, MatrixBridge, REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE, REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX, REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE, REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX, } from "../../src"; describe('MatrixBridge', () => { describe('getRemoteUserInfo', () => { it('should get remote user information', async () => { const userId = "@someone:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteObject = { id: "TESTING_1234", extraKey: true }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const accountDataSpy = simple.stub().callFn((eventType) => { expect(eventType).toEqual(REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE); return Promise.resolve(remoteObject); }); intent.underlyingClient.getAccountData = accountDataSpy as typeof intent.underlyingClient.getAccountData; const bridge = new MatrixBridge(appservice); const result = await bridge.getRemoteUserInfo(intent); expect(result).toBeDefined(); expect(result).toMatchObject(remoteObject); expect(registeredSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); }); }); describe('setRemoteUserInfo', () => { it('should set remote user information', async () => { const userId = "@someone:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteObject = { id: "TESTING_1234", extraKey: true }; const intent = new Intent(options, userId, appservice); const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const accountDataSpy = simple.stub().callFn((eventType, c) => { expect(eventType).toEqual(REMOTE_USER_INFO_ACCOUNT_DATA_EVENT_TYPE); expect(c).toMatchObject(remoteObject); return Promise.resolve(); }); intent.underlyingClient.setAccountData = accountDataSpy; const botIntent = new Intent(options, "@bot:example.org", appservice); (appservice).botIntent = botIntent; // Workaround for using a fake appservice const botRegisteredSpy = simple.mock(botIntent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const botAccountDataSpy = simple.stub().callFn((eventType, c) => { expect(eventType).toEqual(REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX + "." + remoteObject.id); expect(c).toMatchObject({ id: userId }); return Promise.resolve(); }); appservice.botIntent.underlyingClient.setAccountData = botAccountDataSpy; const bridge = new MatrixBridge(appservice); await bridge.setRemoteUserInfo(intent, remoteObject); expect(registeredSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); expect(botRegisteredSpy.callCount).toBe(1); expect(botAccountDataSpy.callCount).toBe(1); }); }); describe('getRemoteRoomInfo', () => { it('should get remote room information', async () => { const userId = "@someone:example.org"; const roomId = "!a:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteObject = { id: "TESTING_1234", extraKey: true }; const intent = new Intent(options, userId, appservice); (appservice).botIntent = intent; // Workaround for using a fake appservice const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const accountDataSpy = simple.stub().callFn((eventType, rid) => { expect(eventType).toEqual(REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE); expect(rid).toEqual(roomId); return Promise.resolve(remoteObject); }); intent.underlyingClient.getRoomAccountData = accountDataSpy as typeof intent.underlyingClient.getRoomAccountData; const bridge = new MatrixBridge(appservice); const result = await bridge.getRemoteRoomInfo(roomId); expect(result).toBeDefined(); expect(result).toMatchObject(remoteObject); expect(registeredSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); }); }); describe('setRemoteRoomInfo', () => { it('should set remote room information', async () => { const userId = "@someone:example.org"; const roomId = "!a:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteObject = { id: "TESTING_1234", extraKey: true }; const intent = new Intent(options, userId, appservice); (appservice).botIntent = intent; // Workaround for using a fake appservice const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const roomAccountDataSpy = simple.stub().callFn((eventType, rid, c) => { expect(eventType).toEqual(REMOTE_ROOM_INFO_ACCOUNT_DATA_EVENT_TYPE); expect(rid).toEqual(roomId); expect(c).toMatchObject(remoteObject); return Promise.resolve(); }); intent.underlyingClient.setRoomAccountData = roomAccountDataSpy; const accountDataSpy = simple.stub().callFn((eventType, c) => { expect(eventType).toEqual(REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX + "." + remoteObject.id); expect(c).toMatchObject({ id: roomId }); return Promise.resolve(); }); intent.underlyingClient.setAccountData = accountDataSpy; const bridge = new MatrixBridge(appservice); await bridge.setRemoteRoomInfo(roomId, remoteObject); expect(registeredSpy.callCount).toBe(2); expect(roomAccountDataSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); }); }); describe('getMatrixRoomIdForRemote', () => { it('should return the right room ID', async () => { const userId = "@someone:example.org"; const roomId = "!a:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteId = "TESTING_1234"; const intent = new Intent(options, userId, appservice); (appservice).botIntent = intent; // Workaround for using a fake appservice const registeredSpy = simple.mock(intent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const accountDataSpy = simple.stub().callFn((eventType) => { expect(eventType).toEqual(REMOTE_ROOM_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX + "." + remoteId); return Promise.resolve({ id: roomId }); }); intent.underlyingClient.getAccountData = accountDataSpy as typeof intent.underlyingClient.getAccountData; const bridge = new MatrixBridge(appservice); const result = await bridge.getMatrixRoomIdForRemote(remoteId); expect(result).toEqual(roomId); expect(registeredSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); }); }); describe('getIntentForRemote', () => { it('should return the right user intent', async () => { const userId = "@someone:example.org"; const asToken = "s3cret"; const hsUrl = "https://localhost"; const appservice = { botUserId: userId }; const options = { homeserverUrl: hsUrl, registration: { as_token: asToken, }, }; const remoteId = "TESTING_1234"; const intent = new Intent(options, userId, appservice); const botIntent = new Intent(options, "@bot:example.org", appservice); (appservice).botIntent = botIntent; // Workaround for using a fake appservice const registeredSpy = simple.mock(botIntent, "ensureRegistered").callFn(() => { return Promise.resolve(); }); const accountDataSpy = simple.stub().callFn((eventType) => { expect(eventType).toEqual(REMOTE_USER_MAP_ACCOUNT_DATA_EVENT_TYPE_PREFIX + "." + remoteId); return Promise.resolve({ id: userId }); }); botIntent.underlyingClient.getAccountData = accountDataSpy as typeof botIntent.underlyingClient.getAccountData; const getIntentSpy = simple.mock(appservice, "getIntentForUserId").callFn((uid) => { expect(uid).toEqual(userId); return intent; }); const bridge = new MatrixBridge(appservice); const result = await bridge.getIntentForRemote(remoteId); expect(result).toEqual(intent); expect(registeredSpy.callCount).toBe(1); expect(accountDataSpy.callCount).toBe(1); expect(getIntentSpy.callCount).toBe(1); }); }); }); ================================================ FILE: test/appservice/UnstableAppserviceApisTest.ts ================================================ import HttpBackend from 'matrix-mock-request'; import { IStorageProvider, MatrixClient, MSC2716BatchSendResponse, UnstableAppserviceApis } from "../../src"; import { createTestClient } from "../TestUtils"; export function createTestUnstableClient( storage: IStorageProvider = null, ): { client: UnstableAppserviceApis; mxClient: MatrixClient; http: HttpBackend; hsUrl: string; accessToken: string; } { const result = createTestClient(storage); const mxClient = result.client; const client = new UnstableAppserviceApis(mxClient); delete result.client; return { ...result, client, mxClient }; } describe('UnstableAppserviceApis', () => { describe('sendHistoricalEventBatch', () => { it('should call the right endpoint', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const events = [{ foo: 5 }, { bar: 10 }]; const stateEvents = [{ baz: 20 }, { pong: 30 }]; const roomId = "!room:example.org"; const prevEventId = "$prevEvent:example.org"; const prevChunkId = "chunkychunkyids"; const expectedResponse = { state_events: ["$stateEv1:example.org", "$stateEv2:example.org"], events: ["$event1:example.org", "$event2:example.org"], next_chunk_id: "evenchunkierid", } as MSC2716BatchSendResponse; http.when("POST", `/_matrix/client/unstable/org.matrix.msc2716/rooms/`).respond(200, (path, content, req) => { expect(path).toEqual(`${hsUrl}/_matrix/client/unstable/org.matrix.msc2716/rooms/${encodeURIComponent(roomId)}/batch_send`); expect(req.queryParams).toMatchObject({ prev_event: prevEventId, chunk_id: prevChunkId, }); expect(content).toMatchObject({ events: events, state_events_at_start: stateEvents, }); return expectedResponse; }); const [result] = await Promise.all([ client.sendHistoricalEventBatch(roomId, prevEventId, events, stateEvents, prevChunkId), http.flushAllExpected(), ]); expect(result).toEqual(expectedResponse); }); }); describe('sendEventWithTimestamp', () => { it('should call the right endpoint with a timestamp', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const eventType = "io.t2bot.test"; const eventContent = { testing: "hello world", sample: true, }; const ts = 5000; http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); expect(req.queryParams).toMatchObject({ ts }); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendEventWithTimestamp(roomId, eventType, eventContent, ts), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); describe('sendStateEvent', () => { it('should call the right endpoint with a timestamp', async () => { const { client, http, hsUrl } = createTestUnstableClient(); const roomId = "!testing:example.org"; const eventId = "$something:example.org"; const stateKey = "testing"; const eventType = "m.room.message"; const eventContent = { body: "Hello World", msgtype: "m.text", sample: true, }; const ts = 5000; http.when("PUT", "/_matrix/client/v3/rooms").respond(200, (path, content, req) => { const idx = path.indexOf(`${hsUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/`); expect(idx).toBe(0); expect(content).toMatchObject(eventContent); expect(req.queryParams).toMatchObject({ ts }); return { event_id: eventId }; }); const [result] = await Promise.all([client.sendStateEventWithTimestamp(roomId, eventType, stateKey, eventContent, ts), http.flushAllExpected()]); expect(result).toEqual(eventId); }); }); }); ================================================ FILE: test/b64Test.ts ================================================ import { decodeBase64, decodeUnpaddedBase64, decodeUnpaddedUrlSafeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedUrlSafeBase64, } from "../src"; function sb(s: string): Buffer { return Buffer.from(s); } describe('b64', () => { it('should be symmetrical', () => { expect(decodeBase64(encodeBase64(sb("test"))).toString()).toBe("test"); expect(decodeUnpaddedBase64(encodeUnpaddedBase64(sb("test"))).toString()).toBe("test"); expect(decodeUnpaddedBase64(encodeBase64(sb("test"))).toString()).toBe("test"); expect(decodeBase64(encodeUnpaddedBase64(sb("test"))).toString()).toBe("test"); expect(decodeUnpaddedUrlSafeBase64(encodeUnpaddedUrlSafeBase64(sb("test"))).toString()).toBe("test"); }); it('should encode', () => { expect(encodeBase64(sb("test"))).toBe("dGVzdA=="); expect(encodeUnpaddedBase64(sb("test"))).toBe("dGVzdA"); expect(encodeUnpaddedUrlSafeBase64(Buffer.from([901231, 123123]))).toBe("b_M"); }); it('should decode', () => { expect(decodeBase64("dGVzdA==").toString()).toBe("test"); expect(decodeUnpaddedBase64("dGVzdA").toString()).toBe("test"); expect(decodeUnpaddedUrlSafeBase64("b_M").join('')).toBe("111243"); }); }); ================================================ FILE: test/encryption/CryptoClientTest.ts ================================================ import * as simple from "simple-mock"; import HttpBackend from 'matrix-mock-request'; import { EncryptedFile, MatrixClient, MembershipEvent, OTKAlgorithm, RoomEncryptionAlgorithm } from "../../src"; import { createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; export function bindNullEngine(http: HttpBackend) { http.when("POST", "/keys/upload").respond(200, (path, obj) => { expect(obj).toMatchObject({ }); return { one_time_key_counts: { // Enough to trick the OlmMachine into thinking it has enough keys [OTKAlgorithm.Signed]: 1000, }, }; }); // Some oddity with the rust-sdk bindings during setup http.when("POST", "/keys/query").respond(200, (path, obj) => { return {}; }); } describe('CryptoClient', () => { it('should not have a device ID or be ready until prepared', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); expect(client.crypto).toBeDefined(); expect(client.crypto.clientDeviceId).toBeFalsy(); expect(client.crypto.isReady).toEqual(false); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); expect(client.crypto.isReady).toEqual(true); })); describe('prepare', () => { it('should prepare the room tracker', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const roomIds = ["!a:example.org", "!b:example.org"]; const { client, http } = createTestClient(null, userId, cryptoStoreType); client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID }); const prepareSpy = simple.stub().callFn((rids: string[]) => { expect(rids).toBe(roomIds); return Promise.resolve(); }); (client.crypto).roomTracker.prepare = prepareSpy; // private member access bindNullEngine(http); await Promise.all([ client.crypto.prepare(roomIds), http.flushAllExpected(), ]); expect(prepareSpy.callCount).toEqual(1); })); it('should use a stored device ID', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" })); client.getWhoAmI = whoamiSpy; bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); expect(whoamiSpy.callCount).toEqual(0); expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID); })); it('should expose the device Ed25519 identity', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); expect(client.crypto.clientDeviceEd25519).toBeTruthy(); })); }); describe('isRoomEncrypted', () => { it('should fail when the crypto has not been prepared', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // await client.crypto.prepare([]); // deliberately commented try { await client.crypto.isRoomEncrypted("!new:example.org"); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } })); it('should return false for unknown rooms', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.reject(new Error("not used")); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(false); })); it('should return false for unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.reject(new Error("implied 404")); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(false); })); it('should return true for encrypted rooms (redacted state)', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.resolve({}); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(true); })); it('should return true for encrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const userId = "@alice:example.org"; const { client, http } = createTestClient(null, userId, cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); client.getRoomStateEvent = () => Promise.resolve({ algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2 }); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const result = await client.crypto.isRoomEncrypted("!new:example.org"); expect(result).toEqual(true); })); }); describe('sign', () => { const userId = "@alice:example.org"; let client: MatrixClient; let http: HttpBackend; beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient, http: mhttp } = createTestClient(null, userId, cryptoStoreType); client = mclient; http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state })); it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.sign({ doesnt: "matter" }); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); it('should sign the object while retaining signatures without mutation', async () => { bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const obj = { sign: "me", signatures: { "@another:example.org": { "ed25519:DEVICE": "signature goes here", }, }, unsigned: { not: "included", }, }; const signatures = await client.crypto.sign(obj); expect(signatures).toMatchObject({ [userId]: { [`ed25519:${TEST_DEVICE_ID}`]: expect.any(String), }, ...obj.signatures, }); expect(obj['signatures']).toBeDefined(); expect(obj['unsigned']).toBeDefined(); }); }); describe('encryptRoomEvent', () => { const userId = "@alice:example.org"; let client: MatrixClient; let http: HttpBackend; beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient, http: mhttp } = createTestClient(null, userId, cryptoStoreType); client = mclient; http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state })); it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.encryptRoomEvent("!room:example.org", "org.example", {}); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); it('should fail in unencrypted rooms', async () => { bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); // Force unencrypted rooms client.crypto.isRoomEncrypted = async () => false; try { await client.crypto.encryptRoomEvent("!room:example.org", "type", {}); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("Room is not encrypted"); } }); it.skip('should get devices for invited members', async () => { // TODO: Support invited members, if history visibility would allow. }); }); describe('decryptRoomEvent', () => { const userId = "@alice:example.org"; let client: MatrixClient; beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient } = createTestClient(null, userId, cryptoStoreType); client = mclient; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state })); it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.decryptRoomEvent(null, null); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); }); describe('encryptMedia', () => { const userId = "@alice:example.org"; let client: MatrixClient; let http: HttpBackend; beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient, http: mhttp } = createTestClient(null, userId, cryptoStoreType); client = mclient; http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state })); it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.encryptMedia(null); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); it('should encrypt media', async () => { bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const inputBuffer = Buffer.from("test"); const inputStr = inputBuffer.join(''); const result = await client.crypto.encryptMedia(inputBuffer); expect(result).toBeDefined(); expect(result.buffer).toBeDefined(); expect(result.buffer.join('')).not.toEqual(inputStr); expect(result.file).toBeDefined(); expect(result.file.hashes).toBeDefined(); expect(result.file.hashes.sha256).not.toEqual("n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg"); expect(result.file).toMatchObject({ hashes: { sha256: expect.any(String), }, key: { alg: "A256CTR", ext: true, key_ops: ['encrypt', 'decrypt'], kty: "oct", k: expect.any(String), }, iv: expect.any(String), v: "v2", }); }); }); describe('decryptMedia', () => { const userId = "@alice:example.org"; let client: MatrixClient; let http: HttpBackend; // Created from Element Web const testFileContents = "THIS IS A TEST FILE."; const mediaFileContents = Buffer.from("eB15hJlkw8WwgYxwY2mu8vS250s=", "base64"); const testFile: EncryptedFile = { v: "v2", key: { alg: "A256CTR", ext: true, k: "l3OtQ3IJzfJa85j2WMsqNu7J--C-I1hzPxFvinR48mM", key_ops: [ "encrypt", "decrypt", ], kty: "oct", }, iv: "KJQOebQS1wwAAAAAAAAAAA", hashes: { sha256: "Qe4YzmVoPaEcLQeZwFZ4iMp/dlgeFph6mi5DmCaCOzg", }, url: "mxc://localhost/uiWuISEVWixompuiiYyUoGrx", }; function copyOfTestFile(): EncryptedFile { return JSON.parse(JSON.stringify(testFile)); } beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient, http: mhttp } = createTestClient(null, userId, cryptoStoreType); client = mclient; http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); // client crypto not prepared for the one test which wants that state })); it('should fail when the crypto has not been prepared', async () => { try { await client.crypto.encryptMedia(null); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); it('should be symmetrical', async () => { bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const mxc = "mxc://example.org/test"; const inputBuffer = Buffer.from("test"); const encrypted = await client.crypto.encryptMedia(inputBuffer); const downloadSpy = simple.stub().callFn(async (u) => { expect(u).toEqual(mxc); return { data: encrypted.buffer, contentType: "application/octet-stream" }; }); client.downloadContent = downloadSpy; const result = await client.crypto.decryptMedia({ url: mxc, ...encrypted.file, }); expect(result.join('')).toEqual(inputBuffer.join('')); expect(downloadSpy.callCount).toBe(1); }); it('should decrypt', async () => { bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const downloadSpy = simple.stub().callFn(async (u) => { expect(u).toEqual(testFile.url); return { data: Buffer.from(mediaFileContents), contentType: "application/octet-stream" }; }); client.downloadContent = downloadSpy; const f = copyOfTestFile(); const result = await client.crypto.decryptMedia(f); expect(result.toString()).toEqual(testFileContents); expect(downloadSpy.callCount).toBe(1); }); }); describe('User Tracking', () => { const userId = "@alice:example.org"; let client: MatrixClient; let http: HttpBackend; beforeEach(() => testCryptoStores(async (cryptoStoreType) => { const { client: mclient, http: mhttp } = createTestClient(null, userId, cryptoStoreType); client = mclient; http = mhttp; await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); })); it('should update tracked users on membership changes', async () => { const targetUserIds = ["@bob:example.org", "@charlie:example.org"]; const prom = new Promise(extResolve => { const trackSpy = simple.mock().callFn((uids) => { expect(uids.length).toBe(1); expect(uids[0]).toEqual(targetUserIds[trackSpy.callCount - 1]); if (trackSpy.callCount === 2) extResolve(); return Promise.resolve(); }); (client.crypto as any).engine.addTrackedUsers = trackSpy; }); for (const targetUserId of targetUserIds) { client.emit("room.event", "!unused:example.org", { type: "m.room.member", state_key: targetUserId, content: { membership: "join" }, sender: targetUserId + ".notthisuser", }); } // Emit a fake update too, to try and trip up the processing client.emit("room.event", "!unused:example.org", { type: "m.room.member", state_key: "@notjoined:example.org", content: { membership: "ban" }, sender: "@notme:example.org", }); // We do weird promise things because `emit()` is sync and we're using async code, so it can // end up not running fast enough for our callCount checks. await prom; }); it('should add all tracked users when the encryption config changes', async () => { // Stub the room tracker (client.crypto as any).roomTracker.onRoomEvent = () => {}; const targetUserIds = ["@bob:example.org", "@charlie:example.org"]; const prom1 = new Promise(extResolve => { (client.crypto as any).engine.addTrackedUsers = simple.mock().callFn((uids) => { expect(uids).toEqual(targetUserIds); extResolve(); return Promise.resolve(); }); }); const roomId = "!room:example.org"; const prom2 = new Promise(extResolve => { client.getRoomMembers = simple.mock().callFn((rid, token, memberships) => { expect(rid).toEqual(roomId); expect(token).toBeFalsy(); expect(memberships).toEqual(["join", "invite"]); extResolve(); return Promise.resolve(targetUserIds.map(u => new MembershipEvent({ type: "m.room.member", state_key: u, content: { membership: "join" }, sender: u, }))); }); }); client.emit("room.event", roomId, { type: "m.room.encryption", state_key: "", content: { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, }, }); // We do weird promise things because `emit()` is sync and we're using async code, so it can // end up not running fast enough for our callCount checks. await Promise.all([prom1, prom2]); }); it('should update the tracked users when joining a new room', async () => { // Stub the room tracker (client.crypto as any).roomTracker.onRoomJoin = () => {}; const targetUserIds = ["@bob:example.org", "@charlie:example.org"]; const prom1 = new Promise(extResolve => { (client.crypto as any).engine.addTrackedUsers = simple.mock().callFn((uids) => { expect(uids).toEqual(targetUserIds); extResolve(); return Promise.resolve(); }); }); const roomId = "!room:example.org"; const prom2 = new Promise(extResolve => { client.getRoomMembers = simple.mock().callFn((rid, token, memberships) => { expect(rid).toEqual(roomId); expect(token).toBeFalsy(); expect(memberships).toEqual(["join", "invite"]); extResolve(); return Promise.resolve(targetUserIds.map(u => new MembershipEvent({ type: "m.room.member", state_key: u, content: { membership: "join" }, sender: u, }))); }); }); client.crypto.isRoomEncrypted = async (rid) => { expect(rid).toEqual(roomId); return true; }; client.emit("room.join", roomId); // We do weird promise things because `emit()` is sync and we're using async code, so it can // end up not running fast enough for our callCount checks. await Promise.all([prom1, prom2]); }); }); }); ================================================ FILE: test/encryption/RoomTrackerTest.ts ================================================ import * as simple from "simple-mock"; import { EncryptionEventContent, MatrixClient, RoomEncryptionAlgorithm, RoomTracker } from "../../src"; import { createTestClient, testCryptoStores, TEST_DEVICE_ID } from "../TestUtils"; import { bindNullEngine } from "./CryptoClientTest"; function prepareQueueSpies( client: MatrixClient, roomId: string, content: Partial = {}, storedContent: Partial = null, ): simple.Stub[] { const readSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(storedContent); }); const stateSpy = simple.stub().callFn((rid: string, eventType: string, stateKey: string) => { expect(rid).toEqual(roomId); expect(eventType).toEqual("m.room.encryption"); expect(stateKey).toEqual(""); return Promise.resolve(content); }); const storeSpy = simple.stub().callFn((rid: string, c: Partial) => { expect(rid).toEqual(roomId); expect(c).toMatchObject({ ...content, algorithm: content['algorithm'] ?? 'UNKNOWN', }); return Promise.resolve(); }); client.cryptoStore.getRoom = readSpy; client.cryptoStore.storeRoom = storeSpy; client.getRoomStateEvent = stateSpy; return [readSpy, stateSpy, storeSpy]; } describe('RoomTracker', () => { it('should queue room updates when rooms are joined', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!a:example.org"; const { client, http } = createTestClient(null, "@user:example.org", cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); (client.crypto as any).engine.addTrackedUsers = () => Promise.resolve(); client.getRoomMembers = () => Promise.resolve([]); const tracker = (client.crypto as any).roomTracker; let queueSpy: simple.Stub; await new Promise(resolve => { queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); resolve(); return Promise.resolve(); }); tracker.queueRoomCheck = queueSpy; client.emit("room.join", roomId); }); expect(queueSpy.callCount).toEqual(1); })); it('should queue room updates when encryption events are received', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!a:example.org"; const { client, http } = createTestClient(null, "@user:example.org", cryptoStoreType); await client.cryptoStore.setDeviceId(TEST_DEVICE_ID); bindNullEngine(http); await Promise.all([ client.crypto.prepare([]), http.flushAllExpected(), ]); const tracker = (client.crypto as any).roomTracker; let queueSpy: simple.Stub; await new Promise(resolve => { queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); resolve(); return Promise.resolve(); }); tracker.queueRoomCheck = queueSpy; client.emit("room.event", roomId, { type: "not-m.room.encryption", state_key: "", }); client.emit("room.event", roomId, { type: "m.room.encryption", state_key: "2", }); client.emit("room.event", roomId, { type: "m.room.encryption", state_key: "", }); }); await new Promise(resolve => setTimeout(() => resolve(), 250)); expect(queueSpy.callCount).toEqual(1); })); describe('prepare', () => { it('should queue updates for rooms', async () => { const roomIds = ["!a:example.org", "!b:example.org"]; const { client } = createTestClient(); const queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomIds[queueSpy.callCount - 1]); return Promise.resolve(); }); const tracker = new RoomTracker(client); tracker.queueRoomCheck = queueSpy; await tracker.prepare(roomIds); expect(queueSpy.callCount).toEqual(2); }); }); describe('queueRoomCheck', () => { it('should store unknown rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); const tracker = new RoomTracker(client); await tracker.queueRoomCheck(roomId); expect(readSpy.callCount).toEqual(1); expect(stateSpy.callCount).toEqual(2); // m.room.encryption and m.room.history_visibility expect(storeSpy.callCount).toEqual(1); })); it('should skip known rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, { algorithm: "no" }, content); const tracker = new RoomTracker(client); await tracker.queueRoomCheck(roomId); expect(readSpy.callCount).toEqual(1); expect(stateSpy.callCount).toEqual(0); expect(storeSpy.callCount).toEqual(0); })); it('should not store unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!b:example.org"; const content = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2, rid: "1" }; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const [readSpy, stateSpy, storeSpy] = prepareQueueSpies(client, roomId, content); client.getRoomStateEvent = async (rid: string, et: string, sk: string) => { await stateSpy(rid, et, sk); throw new Error("Simulated 404"); }; const tracker = new RoomTracker(client); await tracker.queueRoomCheck(roomId); expect(readSpy.callCount).toEqual(1); expect(stateSpy.callCount).toEqual(1); expect(storeSpy.callCount).toEqual(0); })); }); describe('getRoomCryptoConfig', () => { it('should return the config as-is', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!a:example.org"; const content: Partial = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2 }; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const readSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(content); }); client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); const config = await tracker.getRoomCryptoConfig(roomId); expect(readSpy.callCount).toEqual(1); expect(config).toMatchObject(content); })); it('should queue unknown rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!a:example.org"; const content: Partial = { algorithm: RoomEncryptionAlgorithm.MegolmV1AesSha2 }; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const readSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); if (readSpy.callCount === 1) return Promise.resolve(null); return Promise.resolve(content); }); const queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(); }); client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); tracker.queueRoomCheck = queueSpy; const config = await tracker.getRoomCryptoConfig(roomId); expect(readSpy.callCount).toEqual(2); expect(queueSpy.callCount).toEqual(1); expect(config).toMatchObject(content); })); it('should return empty for unencrypted rooms', () => testCryptoStores(async (cryptoStoreType) => { const roomId = "!a:example.org"; const { client } = createTestClient(null, "@user:example.org", cryptoStoreType); const readSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(null); }); const queueSpy = simple.stub().callFn((rid: string) => { expect(rid).toEqual(roomId); return Promise.resolve(); }); client.cryptoStore.getRoom = readSpy; const tracker = new RoomTracker(client); tracker.queueRoomCheck = queueSpy; const config = await tracker.getRoomCryptoConfig(roomId); expect(readSpy.callCount).toEqual(2); expect(queueSpy.callCount).toEqual(1); expect(config).toMatchObject({}); })); }); }); ================================================ FILE: test/encryption/decoratorsTest.ts ================================================ import * as simple from "simple-mock"; import { requiresCrypto, requiresReady } from "../../src"; class InterceptedClass { constructor(private interceptedFn: (i: number) => number, public crypto: any) { } public get isReady() { return this.crypto; } @requiresCrypto() async reqCryptoIntercepted(i: number): Promise { return this.interceptedFn(i); } @requiresReady() async reqReadyIntercepted(i: number): Promise { return this.interceptedFn(i); } } describe('decorators', () => { describe('requiresCrypto', () => { it('should call the intercepted method with provided args', async () => { const amount = 1234; const interceptedFn = simple.stub().callFn((i: number) => { expect(i).toBe(amount); return -1; }); const interceptedClass = new InterceptedClass(interceptedFn, true); await interceptedClass.reqCryptoIntercepted(amount); expect(interceptedFn.callCount).toBe(1); }); it('should return the result of the intercepted method', async () => { const amount = 1234; const interceptedClass = new InterceptedClass((i) => amount, true); const result = await interceptedClass.reqCryptoIntercepted(amount * 2); expect(result).toBe(amount); }); it('should throw if there is no crypto member', async () => { const amount = 1234; const interceptedClass = new InterceptedClass((i) => amount, false); try { await interceptedClass.reqCryptoIntercepted(amount * 2); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to throw"); } catch (e) { expect(e.message).toEqual("End-to-end encryption is not enabled"); } }); it('should throw if the function throws', async () => { const reason = "Bad things"; const interceptedClass = new InterceptedClass(() => { throw new Error(reason); }, true); await expect(interceptedClass.reqCryptoIntercepted(1234)).rejects.toThrow(reason); }); }); describe('requiresReady', () => { it('should call the intercepted method with provided args', async () => { const amount = 1234; const interceptedFn = simple.stub().callFn((i: number) => { expect(i).toBe(amount); return -1; }); const interceptedClass = new InterceptedClass(interceptedFn, true); await interceptedClass.reqReadyIntercepted(amount); expect(interceptedFn.callCount).toBe(1); }); it('should return the result of the intercepted method', async () => { const amount = 1234; const interceptedClass = new InterceptedClass((i) => amount, true); const result = await interceptedClass.reqReadyIntercepted(amount * 2); expect(result).toBe(amount); }); it('should throw if not ready', async () => { const amount = 1234; const interceptedClass = new InterceptedClass((i) => amount, false); try { await interceptedClass.reqReadyIntercepted(amount * 2); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to throw"); } catch (e) { expect(e.message).toEqual("End-to-end encryption has not initialized"); } }); it('should throw if the function throws', async () => { const reason = "Bad things"; const interceptedClass = new InterceptedClass(() => { throw new Error(reason); }, true); await expect(interceptedClass.reqReadyIntercepted(1234)).rejects.toThrow(reason); }); }); }); ================================================ FILE: test/helpers/MatrixEntityTest.ts ================================================ import { MatrixEntity, RoomAlias, UserID } from "../../src"; describe('MatrixEntity', () => { it('should parse arbitrary IDs', () => { const localpart = 'test'; const domain = 'example.org'; const entity = new MatrixEntity(`*${localpart}:${domain}`); expect(entity.localpart).toEqual(localpart); expect(entity.domain).toEqual(domain); }); it('should parse arbitrary IDs with ports in the server name', () => { const localpart = 'test'; const domain = 'example.org:8448'; const entity = new MatrixEntity(`*${localpart}:${domain}`); expect(entity.localpart).toEqual(localpart); expect(entity.domain).toEqual(domain); }); }); describe('UserID', () => { it('should parse user IDs', () => { const localpart = 'test'; const domain = 'example.org'; const entity = new UserID(`@${localpart}:${domain}`); expect(entity.localpart).toEqual(localpart); expect(entity.domain).toEqual(domain); }); }); describe('RoomAlias', () => { it('should parse room aliases', () => { const localpart = 'test'; const domain = 'example.org'; const entity = new RoomAlias(`#${localpart}:${domain}`); expect(entity.localpart).toEqual(localpart); expect(entity.domain).toEqual(domain); }); }); ================================================ FILE: test/helpers/MatrixGlobTest.ts ================================================ import { MatrixGlob } from "../../src"; describe('MatrixGlob', () => { it('should work with no glob characters', () => { const glob = "example.org"; const passingTest = "example.org"; const failingTest = "notexample.org"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with leading glob characters: *', () => { const glob = "*example.org"; const passingTest = "123.example.org"; const failingTest = "example.orgnot"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with trailing glob characters: *', () => { const glob = "example.org*"; const passingTest = "example.org.123"; const failingTest = "notexample.org"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with middle glob characters: *', () => { const glob = "example*.org"; const passingTest = "example123.org"; const failingTest = "notexample.org"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with leading glob characters: ?', () => { const glob = "?example.org"; const passingTest = "1example.org"; const failingTest = "12example.org"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with trailing glob characters: ?', () => { const glob = "example.org?"; const passingTest = "example.org1"; const failingTest = "example.org12"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); it('should work with middle glob characters: ?', () => { const glob = "example?.org"; const passingTest = "example1.org"; const failingTest = "example12.org"; const mtxGlob = new MatrixGlob(glob); expect(mtxGlob.test(passingTest)).toBe(true); expect(mtxGlob.test(failingTest)).toBe(false); }); }); ================================================ FILE: test/helpers/MentionPillTest.ts ================================================ import * as simple from "simple-mock"; import { MentionPill } from "../../src"; import { createTestClient } from "../TestUtils"; describe('MentionPill', () => { describe('forUser', () => { it('should accept static inputs', async () => { const userId = "@test:example.org"; const displayName = "John Doe"; const expectedHtml = `${displayName}`; const expectedText = displayName; const mention = await MentionPill.withDisplayName(userId, displayName); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); }); it('should generate a pill for a user', async () => { const userId = "@test:example.org"; const displayName = userId; //"John Doe"; const expectedHtml = `${displayName}`; const expectedText = displayName; const mention = await MentionPill.forUser(userId); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); }); it('should generate a pill for a user using their profile', async () => { const { client } = createTestClient(); const userId = "@test:example.org"; const displayName = "John Doe"; const expectedHtml = `${displayName}`; const expectedText = displayName; const profileSpy = simple.mock(client, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return { displayname: displayName }; }); const stateSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, eventType, stateKey) => { throw new Error("Unexpected call"); }); const mention = await MentionPill.forUser(userId, null, client); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); expect(profileSpy.callCount).toBe(1); expect(stateSpy.callCount).toBe(0); }); it('should generate a pill for a user using their profile in a room', async () => { const { client } = createTestClient(); const userId = "@test:example.org"; const roomId = "!somewhere:example.org"; const displayName = "John Doe"; const expectedHtml = `${displayName}`; const expectedText = displayName; const profileSpy = simple.mock(client, "getUserProfile").callFn((uid) => { throw new Error("Unexpected call"); }); const stateSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, eventType, stateKey) => { expect(rid).toBe(roomId); expect(eventType).toBe("m.room.member"); expect(stateKey).toBe(userId); return { displayname: displayName }; }); const mention = await MentionPill.forUser(userId, roomId, client); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); expect(profileSpy.callCount).toBe(0); expect(stateSpy.callCount).toBe(1); }); it('should generate use the user ID when the profile errors (profile endpoint)', async () => { const { client } = createTestClient(); const userId = "@test:example.org"; const expectedHtml = `${userId}`; const expectedText = userId; const profileSpy = simple.mock(client, "getUserProfile").callFn((uid) => { throw new Error("Simulated failure 1"); }); const stateSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, eventType, stateKey) => { throw new Error("Simulated failure 2"); }); const mention = await MentionPill.forUser(userId, null, client); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); expect(profileSpy.callCount).toBe(1); expect(stateSpy.callCount).toBe(0); }); it('should generate use the user ID when the profile errors (room endpoint)', async () => { const { client } = createTestClient(); const userId = "@test:example.org"; const roomId = "!somewhere:example.org"; const expectedHtml = `${userId}`; const expectedText = userId; const profileSpy = simple.mock(client, "getUserProfile").callFn((uid) => { throw new Error("Simulated failure 1"); }); const stateSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, eventType, stateKey) => { throw new Error("Simulated failure 2"); }); const mention = await MentionPill.forUser(userId, roomId, client); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); expect(profileSpy.callCount).toBe(0); expect(stateSpy.callCount).toBe(1); }); it('should generate a pill for a room alias', async () => { const roomAlias = "#test:example.org"; const displayName = roomAlias; const expectedHtml = `${displayName}`; const expectedText = displayName; const mention = await MentionPill.forRoom(roomAlias); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); }); it('should generate a pill for a room ID', async () => { const roomId = "!test:example.org"; const displayName = roomId; const expectedHtml = `${displayName}`; const expectedText = displayName; const mention = await MentionPill.forRoom(roomId); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); }); it('should try to fetch the canonical alias for a room', async () => { const { client } = createTestClient(); const roomAlias = "#alias:example.org"; const canonicalAlias = "#canonical:example.org"; const roomId = "!test:example.org"; const expectedHtml = `${canonicalAlias}`; const expectedText = canonicalAlias; const resolveSpy = simple.mock(client, "resolveRoom").callFn(async ref => { expect(ref).toBe(roomAlias); return roomId; }); const getStateSpy = simple.mock(client, "getRoomStateEvent").callFn(async (sRoomId, type, stateKey) => { expect(sRoomId).toBe(roomId); expect(type).toBe("m.room.canonical_alias"); expect(stateKey).toBe(""); return { alias: canonicalAlias }; }); const mention = await MentionPill.forRoom(roomAlias, client); expect(mention).toBeDefined(); expect(mention.html).toBe(expectedHtml); expect(mention.text).toBe(expectedText); expect(getStateSpy.callCount).toBe(1); expect(resolveSpy.callCount).toBe(1); }); }); }); ================================================ FILE: test/helpers/PermalinksTest.ts ================================================ import { PermalinkParts, Permalinks } from "../../src"; describe('Permalinks', () => { describe('forRoom', () => { it('should generate a URL for a room ID', () => { const roomId = "!test:example.org"; const expected = `https://matrix.to/#/${roomId}`; expect(Permalinks.forRoom(roomId)).toBe(expected); }); it('should generate a URL for a room alias', () => { const roomAlias = "#test:example.org"; const expected = `https://matrix.to/#/${roomAlias}`; expect(Permalinks.forRoom(roomAlias)).toBe(expected); }); it('should generate a URL for a room ID with via', () => { const roomId = "!test:example.org"; const via = ['one.example.org', 'two.example.org']; const expected = `https://matrix.to/#/${roomId}?via=${via.join("&via=")}`; expect(Permalinks.forRoom(roomId, via)).toBe(expected); }); it('should generate a URL for a room alias with via', () => { const roomAlias = "#test:example.org"; const via = ['one.example.org', 'two.example.org']; const expected = `https://matrix.to/#/${roomAlias}?via=${via.join("&via=")}`; expect(Permalinks.forRoom(roomAlias, via)).toBe(expected); }); }); describe('forEvent', () => { it('should generate a URL for an event ID with room ID', () => { const roomId = "!test:example.org"; const eventId = "$test:example.org"; const expected = `https://matrix.to/#/${roomId}/${eventId}`; expect(Permalinks.forEvent(roomId, eventId)).toBe(expected); }); it('should generate a URL for an event ID with room alias', () => { const roomAlias = "#test:example.org"; const eventId = "$test:example.org"; const expected = `https://matrix.to/#/${roomAlias}/${eventId}`; expect(Permalinks.forEvent(roomAlias, eventId)).toBe(expected); }); it('should generate a URL for an event ID with room ID with via', () => { const roomId = "!test:example.org"; const eventId = "$test:example.org"; const via = ['one.example.org', 'two.example.org']; const expected = `https://matrix.to/#/${roomId}/${eventId}?via=${via.join("&via=")}`; expect(Permalinks.forEvent(roomId, eventId, via)).toBe(expected); }); it('should generate a URL for an event ID with room alias with via', () => { const roomAlias = "#test:example.org"; const eventId = "$test:example.org"; const via = ['one.example.org', 'two.example.org']; const expected = `https://matrix.to/#/${roomAlias}/${eventId}?via=${via.join("&via=")}`; expect(Permalinks.forEvent(roomAlias, eventId, via)).toBe(expected); }); }); describe('forUser', () => { it('should generate a URL for a user ID', () => { const userId = "@test:example.org"; const expected = `https://matrix.to/#/${userId}`; expect(Permalinks.forUser(userId)).toBe(expected); }); }); describe('parseUrl', () => { it('should parse user URLs', () => { const userId = "@test:example.org"; const expected: PermalinkParts = { userId, roomIdOrAlias: undefined, viaServers: undefined, eventId: undefined, }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${userId}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forUser(userId))).toMatchObject(expected); }); it('should parse room alias URLs', () => { const roomId = "#test:example.org"; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: [], eventId: undefined, }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forRoom(roomId))).toMatchObject(expected); }); it('should parse room ID URLs', () => { const roomId = "!test:example.org"; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: [], eventId: undefined, }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forRoom(roomId))).toMatchObject(expected); }); it('should parse room alias permalink URLs', () => { const roomId = "#test:example.org"; const eventId = "$ev:example.org"; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: [], eventId }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}/${eventId}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forEvent(roomId, eventId))).toMatchObject(expected); }); it('should parse room ID permalink URLs', () => { const roomId = "!test:example.org"; const eventId = "$ev:example.org"; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: [], eventId }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}/${eventId}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forEvent(roomId, eventId))).toMatchObject(expected); }); it('should parse room alias permalink URLs with via servers', () => { const roomId = "#test:example.org"; const eventId = "$ev:example.org"; const via = ["one.example.org", "two.example.org"]; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: via, eventId }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}/${eventId}?via=${via.join("&via=")}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forEvent(roomId, eventId, via))).toMatchObject(expected); }); it('should parse room ID permalink URLs with via servers', () => { const roomId = "!test:example.org"; const eventId = "$ev:example.org"; const via = ["one.example.org", "two.example.org"]; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: via, eventId }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}/${eventId}?via=${via.join("&via=")}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forEvent(roomId, eventId, via))).toMatchObject(expected); }); it('should parse room ID URLs with via servers', () => { const roomId = "!example:example.org"; const via = ["example.org"]; const expected: PermalinkParts = { userId: undefined, roomIdOrAlias: roomId, viaServers: via, eventId: undefined }; const parsed = Permalinks.parseUrl(`https://matrix.to/#/${roomId}/?via=${via.join("&via=")}`); expect(parsed).toMatchObject(expected); expect(Permalinks.parseUrl(Permalinks.forRoom(roomId, via))).toMatchObject(expected); }); }); }); ================================================ FILE: test/helpers/ProfileCacheTest.ts ================================================ import * as simple from "simple-mock"; import { Appservice, ProfileCache } from "../../src"; import { createTestClient, testDelay } from "../TestUtils"; describe('ProfileCache', () => { it('should request the profile if it is not cached', async () => { const userId = "@test:example.org"; const roomId = "!room:example.org"; const roomProfile = { displayname: "Alice", avatar_url: "mxc://example.org/abc" }; const generalProfile = { displayname: "Bob", avatar_url: "mxc://example.org/123" }; const { client } = createTestClient(); const getStateEventSpy = simple.mock(client, "getRoomStateEvent").callFn((rid, type, stateKey) => { expect(rid).toBe(roomId); expect(type).toBe("m.room.member"); expect(stateKey).toBe(userId); return Promise.resolve(roomProfile); }); const getProfileSpy = simple.mock(client, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return Promise.resolve(generalProfile); }); const cache = new ProfileCache(20, 30000, client); let profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(roomProfile.displayname); expect(profile.avatarUrl).toEqual(roomProfile.avatar_url); expect(getStateEventSpy.callCount).toBe(1); expect(getProfileSpy.callCount).toBe(0); // Make sure it cached it profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(roomProfile.displayname); expect(profile.avatarUrl).toEqual(roomProfile.avatar_url); expect(getStateEventSpy.callCount).toBe(1); expect(getProfileSpy.callCount).toBe(0); // Now check the general profile profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy.callCount).toBe(1); expect(getProfileSpy.callCount).toBe(1); // Make sure it cached it profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy.callCount).toBe(1); expect(getProfileSpy.callCount).toBe(1); }); it('should watch for membership updates with a MatrixClient', async () => { const userId = "@test:example.org"; const roomId = "!room:example.org"; const roomProfile = { displayname: "Alice", avatar_url: "mxc://example.org/abc" }; let generalProfile = { displayname: "Bob", avatar_url: "mxc://example.org/123" }; const altRoomProfile = { displayname: "Charlie", avatar_url: "mxc://example.org/456" }; const membershipEvent = { state_key: userId, type: 'm.room.member', content: altRoomProfile }; const { client: client1 } = createTestClient(); const { client: client2 } = createTestClient(); const getStateEventSpy1 = simple.mock(client1, "getRoomStateEvent").callFn((rid, type, stateKey) => { expect(rid).toBe(roomId); expect(type).toBe("m.room.member"); expect(stateKey).toBe(userId); return Promise.resolve(roomProfile); }); const getProfileSpy1 = simple.mock(client1, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return Promise.resolve(generalProfile); }); const getStateEventSpy2 = simple.mock(client2, "getRoomStateEvent").callFn((rid, type, stateKey) => { expect(rid).toBe(roomId); expect(type).toBe("m.room.member"); expect(stateKey).toBe(userId); return Promise.resolve(roomProfile); }); const getProfileSpy2 = simple.mock(client2, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return Promise.resolve(generalProfile); }); const cache = new ProfileCache(20, 30000, client1); // Watch for changes cache.watchWithClient(client2); // Ensure nothing changes when an update happens client2.emit("room.event", roomId, membershipEvent); expect(getStateEventSpy1.callCount).toBe(0); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Get the profile once to cache it let profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(roomProfile.displayname); expect(profile.avatarUrl).toEqual(roomProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Ensure it's updated from the right client (which should be none because it's a membership event) client2.emit("room.event", roomId, membershipEvent); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Verify the profile got updated profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(altRoomProfile.displayname); expect(profile.avatarUrl).toEqual(altRoomProfile.avatar_url); // Now check the general profile (cache it first) profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Change the profile slightly and expect it to update generalProfile = { displayname: "Daniel", avatar_url: "mxc://example.org/def" }; client2.emit("room.event", roomId, membershipEvent); await testDelay(100); // Let the promises settle. expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(1); profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(1); }); it('should watch for membership updates with an appservice', async () => { const userId = "@test:example.org"; const roomId = "!room:example.org"; const roomProfile = { displayname: "Alice", avatar_url: "mxc://example.org/abc" }; let generalProfile = { displayname: "Bob", avatar_url: "mxc://example.org/123" }; const altRoomProfile = { displayname: "Charlie", avatar_url: "mxc://example.org/456" }; const membershipEvent = { state_key: userId, type: 'm.room.member', content: altRoomProfile }; const { client: client1 } = createTestClient(); const { client: client2 } = createTestClient(); const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const getStateEventSpy1 = simple.mock(client1, "getRoomStateEvent").callFn((rid, type, stateKey) => { expect(rid).toBe(roomId); expect(type).toBe("m.room.member"); expect(stateKey).toBe(userId); return Promise.resolve(roomProfile); }); const getProfileSpy1 = simple.mock(client1, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return Promise.resolve(generalProfile); }); const getStateEventSpy2 = simple.mock(client2, "getRoomStateEvent").callFn((rid, type, stateKey) => { expect(rid).toBe(roomId); expect(type).toBe("m.room.member"); expect(stateKey).toBe(userId); return Promise.resolve(roomProfile); }); const getProfileSpy2 = simple.mock(client2, "getUserProfile").callFn((uid) => { expect(uid).toEqual(userId); return Promise.resolve(generalProfile); }); const cache = new ProfileCache(20, 30000, client1); // Watch for changes const clientFnSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return client2; }); cache.watchWithAppservice(appservice, clientFnSpy); // Ensure nothing changes when an update happens appservice.emit("room.event", roomId, membershipEvent); expect(clientFnSpy.callCount).toBe(1); expect(getStateEventSpy1.callCount).toBe(0); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Get the profile once to cache it let profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(roomProfile.displayname); expect(profile.avatarUrl).toEqual(roomProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Ensure it's updated from the right client (which should be none because it's a membership event) appservice.emit("room.event", roomId, membershipEvent); expect(clientFnSpy.callCount).toBe(2); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(0); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Verify the profile got updated profile = await cache.getUserProfile(userId, roomId); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(altRoomProfile.displayname); expect(profile.avatarUrl).toEqual(altRoomProfile.avatar_url); // Now check the general profile (cache it first) profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(0); // Change the profile slightly and expect it to update generalProfile = { displayname: "Daniel", avatar_url: "mxc://example.org/def" }; appservice.emit("room.event", roomId, membershipEvent); await testDelay(100); // Let the promises settle. expect(clientFnSpy.callCount).toBe(3); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(1); profile = await cache.getUserProfile(userId, null); expect(profile).toBeDefined(); expect(profile.displayName).toEqual(generalProfile.displayname); expect(profile.avatarUrl).toEqual(generalProfile.avatar_url); expect(getStateEventSpy1.callCount).toBe(1); expect(getProfileSpy1.callCount).toBe(1); expect(getStateEventSpy2.callCount).toBe(0); expect(getProfileSpy2.callCount).toBe(1); }); }); ================================================ FILE: test/helpers/RichReplyTest.ts ================================================ import { RichReply } from "../../src"; describe('RichReply', () => { it('should return a well-formatted reply', () => { const inputEvent = { content: { body: "*Hello World*", formatted_body: "Hello World", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const inputRoomId = "!abc:example.org"; const replyText = "**Testing 1234**"; const replyHtml = "Testing 1234"; const reply = RichReply.createFor(inputRoomId, inputEvent, replyText, replyHtml); const expectedReply = { "m.relates_to": { "m.in_reply_to": { "event_id": inputEvent.event_id, }, }, "msgtype": "m.text", "body": `> <${inputEvent.sender}> ${inputEvent.content.body}\n\n${replyText}`, "format": "org.matrix.custom.html", // eslint-disable-next-line max-len "formatted_body": `
In reply to ${inputEvent.sender}
${inputEvent.content.formatted_body}
${replyHtml}`, }; expect(reply).toMatchObject(expectedReply); }); it('should return a well-formatted multiline reply', () => { const inputEvent = { content: { body: "*Hello World*\nHow are you?", formatted_body: "Hello World
How are you?", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const inputRoomId = "!abc:example.org"; const replyText = "**Testing 1234**\nThis is a test"; const replyHtml = "Testing 1234
This is a test"; const reply = RichReply.createFor(inputRoomId, inputEvent, replyText, replyHtml); const expectedReply = { "m.relates_to": { "m.in_reply_to": { "event_id": inputEvent.event_id, }, }, "msgtype": "m.text", "body": `> <${inputEvent.sender}> ${inputEvent.content.body.split('\n').join('\n> ')}\n\n${replyText}`, "format": "org.matrix.custom.html", // eslint-disable-next-line max-len "formatted_body": `
In reply to ${inputEvent.sender}
${inputEvent.content.formatted_body}
${replyHtml}`, }; expect(reply).toMatchObject(expectedReply); }); it('should be able to reply to plaintext events', () => { const inputEvent = { content: { body: "*Hello World*", }, sender: "@abc:example.org", event_id: "$abc:example.org", }; const inputRoomId = "!abc:example.org"; const replyText = "**Testing 1234**"; const replyHtml = "Testing 1234"; const reply = RichReply.createFor(inputRoomId, inputEvent, replyText, replyHtml); const expectedReply = { "m.relates_to": { "m.in_reply_to": { "event_id": inputEvent.event_id, }, }, "msgtype": "m.text", "body": `> <${inputEvent.sender}> ${inputEvent.content.body}\n\n${replyText}`, "format": "org.matrix.custom.html", // eslint-disable-next-line max-len "formatted_body": `
In reply to ${inputEvent.sender}
${inputEvent.content.body}
${replyHtml}`, }; expect(reply).toMatchObject(expectedReply); }); }); ================================================ FILE: test/helpers/UnpaddedBase64Test.ts ================================================ import { UnpaddedBase64 } from "../../src"; describe('UnpaddedBase64', () => { it('should encode buffers', () => { expect(UnpaddedBase64.encodeBuffer(Buffer.from(""))).toEqual(""); expect(UnpaddedBase64.encodeBuffer(Buffer.from("f"))).toEqual("Zg"); expect(UnpaddedBase64.encodeBuffer(Buffer.from("fo"))).toEqual("Zm8"); expect(UnpaddedBase64.encodeBuffer(Buffer.from("foo"))).toEqual("Zm9v"); expect(UnpaddedBase64.encodeBuffer(Buffer.from("foob"))).toEqual("Zm9vYg"); expect(UnpaddedBase64.encodeBuffer(Buffer.from("fooba"))).toEqual("Zm9vYmE"); expect(UnpaddedBase64.encodeBuffer(Buffer.from("foobar"))).toEqual("Zm9vYmFy"); }); it('should encode strings', () => { expect(UnpaddedBase64.encodeString("")).toEqual(""); expect(UnpaddedBase64.encodeString("f")).toEqual("Zg"); expect(UnpaddedBase64.encodeString("fo")).toEqual("Zm8"); expect(UnpaddedBase64.encodeString("foo")).toEqual("Zm9v"); expect(UnpaddedBase64.encodeString("foob")).toEqual("Zm9vYg"); expect(UnpaddedBase64.encodeString("fooba")).toEqual("Zm9vYmE"); expect(UnpaddedBase64.encodeString("foobar")).toEqual("Zm9vYmFy"); }); it('should encode buffers (url safe)', () => { expect(UnpaddedBase64.encodeBufferUrlSafe(Buffer.from("😄😄🎉👀👷‍♂️👼"))).toEqual("8J-YhPCfmITwn46J8J-RgPCfkbfigI3imYLvuI_wn5G8"); }); it('should encode strings (url safe)', () => { expect(UnpaddedBase64.encodeStringUrlSafe("😄😄🎉👀👷‍♂️👼")).toEqual("8J-YhPCfmITwn46J8J-RgPCfkbfigI3imYLvuI_wn5G8"); }); }); ================================================ FILE: test/logging/LogServiceTest.ts ================================================ import * as simple from "simple-mock"; import { ConsoleLogger, LogLevel, LogService } from "../../src"; describe('LogService', () => { afterEach(() => LogService.setLogger(new ConsoleLogger())); it('should log to the INFO channel', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: logSpy, warn: null, error: null, debug: null, trace: null }); LogService.info(module, a1, a2); expect(logSpy.callCount).toBe(1); }); it('should log to the ERROR channel', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: null, warn: null, error: logSpy, debug: null, trace: null }); LogService.error(module, a1, a2); expect(logSpy.callCount).toBe(1); }); it('should log to the WARN channel', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: null, warn: logSpy, error: null, debug: null, trace: null }); LogService.warn(module, a1, a2); expect(logSpy.callCount).toBe(1); }); it('should log to the DEBUG channel', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLevel(LogLevel.DEBUG); LogService.setLogger({ info: null, warn: null, error: null, debug: logSpy, trace: null }); LogService.debug(module, a1, a2); expect(logSpy.callCount).toBe(1); }); it('should log to the TRACE channel', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLevel(LogLevel.TRACE); LogService.setLogger({ info: null, warn: null, error: null, debug: null, trace: logSpy }); LogService.trace(module, a1, a2); expect(logSpy.callCount).toBe(1); }); it('should not log to the TRACE channel when the log level is higher', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: null, warn: null, error: null, debug: null, trace: logSpy }); LogService.setLevel(LogLevel.DEBUG); LogService.trace(module, a1, a2); expect(logSpy.callCount).toBe(0); }); it('should not log to the DEBUG channel when the log level is higher', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: null, warn: null, error: null, debug: logSpy, trace: null }); LogService.setLevel(LogLevel.INFO); LogService.debug(module, a1, a2); expect(logSpy.callCount).toBe(0); }); it('should not log to the INFO channel when the log level is higher', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: logSpy, warn: null, error: null, debug: null, trace: null }); LogService.setLevel(LogLevel.WARN); LogService.info(module, a1, a2); expect(logSpy.callCount).toBe(0); }); it('should not log to the WARN channel when the log level is higher', () => { const module = "Testing Module"; const a1 = "This is a message"; const a2 = { hello: "world" }; const logSpy = simple.stub().callFn((m, arg1, arg2) => { expect(m).toEqual(module); expect(arg1).toEqual(a1); expect(arg2).toEqual(a2); }); LogService.setLogger({ info: null, warn: logSpy, error: null, debug: null, trace: null }); LogService.setLevel(LogLevel.ERROR); LogService.warn(module, a1, a2); expect(logSpy.callCount).toBe(0); }); it('should mute the requested modules', () => { const mutedModule = "Mute Me"; const unmutedModule = "Hello World"; const logSpy = simple.stub().callFn((m) => { expect(m).toEqual(unmutedModule); }); LogService.setLogger({ info: logSpy, warn: logSpy, error: logSpy, debug: logSpy, trace: logSpy }); LogService.setLevel(LogLevel.TRACE); LogService.muteModule(mutedModule); LogService.trace(mutedModule, "test"); LogService.debug(mutedModule, "test"); LogService.info(mutedModule, "test"); LogService.warn(mutedModule, "test"); LogService.error(mutedModule, "test"); LogService.trace(unmutedModule, "test"); LogService.debug(unmutedModule, "test"); LogService.info(unmutedModule, "test"); LogService.warn(unmutedModule, "test"); LogService.error(unmutedModule, "test"); expect(logSpy.callCount).toBe(5); }); }); ================================================ FILE: test/metrics/MetricsTest.ts ================================================ import * as simple from "simple-mock"; import { IMetricContext, IMetricListener, Metrics } from "../../src"; function createTestMetricListener(expectedName: string, expectedContext: IMetricContext, validateNumberFn: (i: number) => void): IMetricListener { return { onIncrement: simple.stub().callFn((name: string, context: IMetricContext, amount: number) => { expect(name).toBe(expectedName); expect(context).toMatchObject(expectedContext); validateNumberFn(amount); }), onDecrement: simple.stub().callFn((name: string, context: IMetricContext, amount: number) => { expect(name).toBe(expectedName); expect(context).toMatchObject(expectedContext); validateNumberFn(amount); }), onReset: simple.stub().callFn((name: string, context: IMetricContext) => { expect(name).toBe(expectedName); expect(context).toMatchObject(expectedContext); }), onStartMetric: simple.stub().callFn((name: string, context: IMetricContext) => { expect(name).toBe(expectedName); expect(context).toMatchObject(expectedContext); }), onEndMetric: simple.stub().callFn((name: string, context: IMetricContext, timeMs: number) => { expect(name).toBe(expectedName); expect(context).toMatchObject(expectedContext); validateNumberFn(timeMs); }), }; } describe('Metrics', () => { it('should support listeners', async () => { const metrics = new Metrics(); const listeners = () => (metrics).listeners; // Verify that our hack is in the right place expect(listeners()).toBeDefined(); expect(listeners().length).toBe(0); const listener = {}; metrics.registerListener(listener); expect(listeners()).toBeDefined(); expect(listeners().length).toBe(1); metrics.unregisterListener(listener); expect(listeners()).toBeDefined(); expect(listeners().length).toBe(0); }); it('should track time series metrics', async () => { const metrics = new Metrics(); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const tolerance = 50; const delayMs = 200; const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBeGreaterThan(delayMs - tolerance); expect(i).toBeLessThan(delayMs + tolerance); }); metrics.registerListener(listener); metrics.start(metricName, context); await new Promise((resolve) => { setTimeout(resolve, delayMs); }); metrics.end(metricName, context); expect((listener.onStartMetric).callCount).toBe(1); expect((listener.onEndMetric).callCount).toBe(1); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(0); }); it('should track time series metrics with parent', async () => { const parentMetrics = new Metrics(); const metrics = new Metrics(parentMetrics); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const tolerance = 50; const delayMs = 200; const parentListener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBeGreaterThan(delayMs - tolerance); expect(i).toBeLessThan(delayMs + tolerance); }); parentMetrics.registerListener(parentListener); const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBeGreaterThan(delayMs - tolerance); expect(i).toBeLessThan(delayMs + tolerance); }); metrics.registerListener(listener); metrics.start(metricName, context); await new Promise((resolve) => { setTimeout(resolve, delayMs); }); metrics.end(metricName, context); expect((parentListener.onStartMetric).callCount).toBe(1); expect((parentListener.onEndMetric).callCount).toBe(1); expect((parentListener.onIncrement).callCount).toBe(0); expect((parentListener.onDecrement).callCount).toBe(0); expect((parentListener.onReset).callCount).toBe(0); expect((listener.onStartMetric).callCount).toBe(1); expect((listener.onEndMetric).callCount).toBe(1); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(0); }); describe('increment', () => { it('should increment', async () => { const metrics = new Metrics(); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const amount = 15; const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); metrics.registerListener(listener); metrics.increment(metricName, context, amount); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(1); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(0); }); it('should increment with parent', async () => { const parentMetrics = new Metrics(); const metrics = new Metrics(parentMetrics); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const amount = 15; const parentListener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); parentMetrics.registerListener(parentListener); const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); metrics.registerListener(listener); metrics.increment(metricName, context, amount); expect((parentListener.onStartMetric).callCount).toBe(0); expect((parentListener.onEndMetric).callCount).toBe(0); expect((parentListener.onIncrement).callCount).toBe(1); expect((parentListener.onDecrement).callCount).toBe(0); expect((parentListener.onReset).callCount).toBe(0); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(1); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(0); }); }); describe('decrement', () => { it('should decrement', async () => { const metrics = new Metrics(); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const amount = 15; const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); metrics.registerListener(listener); metrics.decrement(metricName, context, amount); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(1); expect((listener.onReset).callCount).toBe(0); }); it('should decrement with parent', async () => { const parentMetrics = new Metrics(); const metrics = new Metrics(parentMetrics); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const amount = 15; const parentListener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); parentMetrics.registerListener(parentListener); const listener = createTestMetricListener(metricName, context, (i: number) => { expect(i).toBe(amount); }); metrics.registerListener(listener); metrics.decrement(metricName, context, amount); expect((parentListener.onStartMetric).callCount).toBe(0); expect((parentListener.onEndMetric).callCount).toBe(0); expect((parentListener.onIncrement).callCount).toBe(0); expect((parentListener.onDecrement).callCount).toBe(1); expect((parentListener.onReset).callCount).toBe(0); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(1); expect((listener.onReset).callCount).toBe(0); }); }); describe('reset', () => { it('should reset', async () => { const metrics = new Metrics(); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const listener = createTestMetricListener(metricName, context, (i: number) => { throw new Error("Unexpected number"); }); metrics.registerListener(listener); metrics.reset(metricName, context); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(1); }); it('should reset with parent', async () => { const parentMetrics = new Metrics(); const metrics = new Metrics(parentMetrics); const context = { uniqueId: "test1234", hello: "world" }; const metricName = "test_metric"; const parentListener = createTestMetricListener(metricName, context, (i: number) => { throw new Error("Unexpected number"); }); parentMetrics.registerListener(parentListener); const listener = createTestMetricListener(metricName, context, (i: number) => { throw new Error("Unexpected number"); }); metrics.registerListener(listener); metrics.reset(metricName, context); expect((parentListener.onStartMetric).callCount).toBe(0); expect((parentListener.onEndMetric).callCount).toBe(0); expect((parentListener.onIncrement).callCount).toBe(0); expect((parentListener.onDecrement).callCount).toBe(0); expect((parentListener.onReset).callCount).toBe(1); expect((listener.onStartMetric).callCount).toBe(0); expect((listener.onEndMetric).callCount).toBe(0); expect((listener.onIncrement).callCount).toBe(0); expect((listener.onDecrement).callCount).toBe(0); expect((listener.onReset).callCount).toBe(1); }); }); }); ================================================ FILE: test/metrics/decoratorsTest.ts ================================================ import * as simple from "simple-mock"; import { Metrics, timedIdentityClientFunctionCall, timedIntentFunctionCall, timedMatrixClientFunctionCall, } from "../../src"; class InterceptedClass { public client: any; constructor(private metrics: Metrics, private interceptedFn: (i: number) => number) { this.client = { userId: 5678 }; } @timedMatrixClientFunctionCall() async matrixClientIntercepted(i: number): Promise { return this.interceptedFn(i); } @timedIdentityClientFunctionCall() async identityClientIntercepted(i: number): Promise { return this.interceptedFn(i); } @timedIntentFunctionCall() async intentIntercepted(i: number): Promise { return this.interceptedFn(i); } } // Not a fan of this but the promise with metrics chained isn't returned, just the original before the metrics were chained // I think this is deliberate so that metrics slow down any promises that later get chained // If we could return the promise with metrics chained this can go away. const waitingPromise = () => new Promise((resolve) => setTimeout(resolve, 10)); describe('decorators', () => { describe('timedMatrixClientFunctionCall', () => { it('should call the intercepted method with provided args', async () => { const amount = 1234; const interceptedFn = simple.stub().callFn((i: number) => { expect(i).toBe(amount); return -1; }); const interceptedClass = new InterceptedClass(new Metrics(), interceptedFn); await interceptedClass.matrixClientIntercepted(amount); expect(interceptedFn.callCount).toBe(1); }); it('should return the result of the intercepted method', async () => { const amount = 1234; const interceptedClass = new InterceptedClass(new Metrics(), (i) => amount); const result = await interceptedClass.matrixClientIntercepted(amount * 2); expect(result).toBe(amount); }); it('should expose errors from the intercepted method', async () => { const reason = "Bad things"; const interceptedClass = new InterceptedClass(new Metrics(), () => { throw new Error(reason); }); await expect(interceptedClass.matrixClientIntercepted(1234)).rejects.toThrow(reason); }); it('should call start on metrics with function name before calling intercepted method', async () => { const metrics = new Metrics(); const startFn = simple.mock(metrics, "start"); const interceptedFn = simple.stub().callFn((i: number) => { expect(startFn.callCount).toBe(1); expect(startFn.lastCall.args[0]).toBe("matrix_client_function_call"); expect(startFn.lastCall.args[1]).toHaveProperty("functionName", "matrixClientIntercepted"); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.matrixClientIntercepted(1234); }); it('should call end on metrics with function name after calling intercepted method', async () => { const metrics = new Metrics(); const endFn = simple.mock(metrics, "end"); const interceptedFn = simple.stub().callFn((i: number) => { expect(endFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.matrixClientIntercepted(1234).then(waitingPromise); expect(endFn.callCount).toBe(1); expect(endFn.lastCall.args[0]).toBe("matrix_client_function_call"); expect(endFn.lastCall.args[1]).toHaveProperty("functionName", "matrixClientIntercepted"); }); it('should increment the successful counter on returning a result', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.matrixClientIntercepted(1234).then(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("matrix_client_successful_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "matrixClientIntercepted"); }); it('should increment the failure counter on throwing', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); throw new Error("Bad things"); }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.matrixClientIntercepted(1234).catch(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("matrix_client_failed_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "matrixClientIntercepted"); }); }); describe('timedIdentityClientFunctionCall', () => { it('should call the intercepted method with provided args', async () => { const amount = 1234; const interceptedFn = simple.stub().callFn((i: number) => { expect(i).toBe(amount); return -1; }); const interceptedClass = new InterceptedClass(new Metrics(), interceptedFn); await interceptedClass.identityClientIntercepted(amount); expect(interceptedFn.callCount).toBe(1); }); it('should return the result of the intercepted method', async () => { const amount = 1234; const interceptedClass = new InterceptedClass(new Metrics(), (i) => amount); const result = await interceptedClass.identityClientIntercepted(amount * 2); expect(result).toBe(amount); }); it('should expose errors from the intercepted method', async () => { const reason = "Bad things"; const interceptedClass = new InterceptedClass(new Metrics(), () => { throw new Error(reason); }); await expect(interceptedClass.identityClientIntercepted(1234)).rejects.toThrow(reason); }); it('should call start on metrics with function name before calling intercepted method', async () => { const metrics = new Metrics(); const startFn = simple.mock(metrics, "start"); const interceptedFn = simple.stub().callFn((i: number) => { expect(startFn.callCount).toBe(1); expect(startFn.lastCall.args[0]).toBe("identity_client_function_call"); expect(startFn.lastCall.args[1]).toHaveProperty("functionName", "identityClientIntercepted"); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.identityClientIntercepted(1234); }); it('should call end on metrics with function name after calling intercepted method', async () => { const metrics = new Metrics(); const endFn = simple.mock(metrics, "end"); const interceptedFn = simple.stub().callFn((i: number) => { expect(endFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.identityClientIntercepted(1234).then(waitingPromise); expect(endFn.callCount).toBe(1); expect(endFn.lastCall.args[0]).toBe("identity_client_function_call"); expect(endFn.lastCall.args[1]).toHaveProperty("functionName", "identityClientIntercepted"); }); it('should increment the successful counter on returning a result', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.identityClientIntercepted(1234).then(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("identity_client_successful_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "identityClientIntercepted"); }); it('should increment the failure counter on throwing', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); throw new Error("Bad things"); }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.identityClientIntercepted(1234).catch(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("identity_client_failed_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "identityClientIntercepted"); }); }); describe('timedIntentFunctionCall', () => { it('should call the intercepted method with provided args', async () => { const amount = 1234; const interceptedFn = simple.stub().callFn((i: number) => { expect(i).toBe(amount); return -1; }); const interceptedClass = new InterceptedClass(new Metrics(), interceptedFn); await interceptedClass.intentIntercepted(amount); expect(interceptedFn.callCount).toBe(1); }); it('should return the result of the intercepted method', async () => { const amount = 1234; const interceptedClass = new InterceptedClass(new Metrics(), (i) => amount); const result = await interceptedClass.intentIntercepted(amount * 2); expect(result).toBe(amount); }); it('should expose errors from the intercepted method', async () => { const reason = "Bad things"; const interceptedClass = new InterceptedClass(new Metrics(), () => { throw new Error(reason); }); await expect(interceptedClass.intentIntercepted(1234)).rejects.toThrow(reason); }); it('should call start on metrics with function name before calling intercepted method', async () => { const metrics = new Metrics(); const startFn = simple.mock(metrics, "start"); const interceptedFn = simple.stub().callFn((i: number) => { expect(startFn.callCount).toBe(1); expect(startFn.lastCall.args[0]).toBe("intent_function_call"); expect(startFn.lastCall.args[1]).toHaveProperty("functionName", "intentIntercepted"); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.intentIntercepted(1234); }); it('should call end on metrics with function name after calling intercepted method', async () => { const metrics = new Metrics(); const endFn = simple.mock(metrics, "end"); const interceptedFn = simple.stub().callFn((i: number) => { expect(endFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.intentIntercepted(1234).then(waitingPromise); expect(endFn.callCount).toBe(1); expect(endFn.lastCall.args[0]).toBe("intent_function_call"); expect(endFn.lastCall.args[1]).toHaveProperty("functionName", "intentIntercepted"); }); it('should increment the successful counter on returning a result', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); return -1; }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.intentIntercepted(1234).then(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("intent_successful_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "intentIntercepted"); }); it('should increment the failure counter on throwing', async () => { const metrics = new Metrics(); const incrementFn = simple.mock(metrics, "increment"); const interceptedFn = simple.stub().callFn((i: number) => { expect(incrementFn.callCount).toBe(0); throw new Error("Bad things"); }); const interceptedClass = new InterceptedClass(metrics, interceptedFn); await interceptedClass.intentIntercepted(1234).catch(waitingPromise); expect(incrementFn.callCount).toBe(1); expect(incrementFn.lastCall.args[0]).toBe("intent_failed_function_call"); expect(incrementFn.lastCall.args[1]).toHaveProperty("functionName", "intentIntercepted"); }); }); }); ================================================ FILE: test/mixins/AutojoinRoomsMixinTest.ts ================================================ import * as simple from "simple-mock"; import { Appservice, AutojoinRoomsMixin, Intent } from "../../src"; import { createTestClient } from "../TestUtils"; describe('AutojoinRoomsMixin', () => { it('should join rooms for regular invites', () => { const { client } = createTestClient(); const roomId = "!test:example.org"; const joinSpy = simple.mock(client, "joinRoom").callFn((rid) => { expect(rid).toEqual(roomId); }); AutojoinRoomsMixin.setupOnClient(client); client.emit("room.invite", roomId, {}); expect(joinSpy.callCount).toBe(1); }); it('should join rooms for appservice invites', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const roomId = "!test:example.org"; const userId = "@join:example.org"; const event = { type: "m.room.test", state_key: userId }; const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); }); appservice.getIntentForUserId = (uid) => { expect(uid).toEqual(userId); return { joinRoom: joinSpy, } as unknown as Intent; }; AutojoinRoomsMixin.setupOnAppservice(appservice); appservice.emit("room.invite", roomId, event); expect(joinSpy.callCount).toBe(1); }); it('should join rooms for appservice invites with conditions', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const notBotUserId = "@NOT_BOT:example.org"; const okRoomId = "!ok:example.org"; const okUserId = "@ok:example.org"; const okEvent = { type: "m.room.ok", state_key: okUserId, sender: notBotUserId }; const badRoomId = "!bad:example.org"; const badUserId = "@bad:example.org"; const badEvent = { type: "m.room.bad", state_key: badUserId, sender: notBotUserId }; const joinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(okRoomId); }); appservice.getIntentForUserId = (uid) => { expect(uid).toEqual(okUserId); return { joinRoom: joinSpy, } as unknown as Intent; }; const conditional = simple.stub().callFn((ev) => { expect(ev).toBeDefined(); if (ev['type'] === 'm.room.ok') { expect(ev).toMatchObject(okEvent); return true; } else if (ev['type'] === 'm.room.bad') { expect(ev).toMatchObject(badEvent); return false; } else { throw new Error("Unexpected event"); } }); AutojoinRoomsMixin.setupOnAppservice(appservice, conditional); appservice.emit("room.invite", okRoomId, okEvent); expect(joinSpy.callCount).toBe(1); expect(conditional.callCount).toBe(1); appservice.emit("room.invite", badRoomId, badEvent); expect(joinSpy.callCount).toBe(1); expect(conditional.callCount).toBe(2); }); it('should join rooms from the bot without a conditional', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const botUserId = "@_bot_:example.org"; const okRoomId = "!ok:example.org"; const okUserId = "@ok:example.org"; const okEvent = { type: "m.room.ok", state_key: okUserId, sender: botUserId }; const badRoomId = "!bad:example.org"; const badUserId = "@bad:example.org"; const badEvent = { type: "m.room.bad", state_key: badUserId, sender: botUserId }; const joinSpy = simple.stub().callFn((rid) => { if (rid !== okRoomId && rid !== badRoomId) throw new Error("Unexpected room ID"); }); appservice.getIntentForUserId = (uid) => { if (uid !== okUserId && uid !== badUserId) throw new Error("Unexpected user ID"); return { joinRoom: joinSpy, } as unknown as Intent; }; const conditional = simple.stub().callFn((ev) => { expect(ev).toBeDefined(); if (ev['type'] === 'm.room.ok') { expect(ev).toMatchObject(okEvent); return true; } else if (ev['type'] === 'm.room.bad') { expect(ev).toMatchObject(badEvent); return false; } else { throw new Error("Unexpected event"); } }); AutojoinRoomsMixin.setupOnAppservice(appservice, conditional); appservice.emit("room.invite", okRoomId, okEvent); expect(joinSpy.callCount).toBe(1); expect(conditional.callCount).toBe(0); appservice.emit("room.invite", badRoomId, badEvent); expect(joinSpy.callCount).toBe(2); expect(conditional.callCount).toBe(0); }); }); ================================================ FILE: test/mixins/AutojoinUpgradedRoomsMixinTest.ts ================================================ import * as simple from "simple-mock"; import { Appservice, AutojoinUpgradedRoomsMixin, Intent } from "../../src"; import { createTestClient } from "../TestUtils"; describe('AutojoinUpgradedRoomsMixin', () => { it('should join rooms for regular upgrades', () => { const { client } = createTestClient(); const senderServer = "localhost:8448"; const roomId = "!test:example.org"; const newRoomId = "!new:example.org"; const joinSpy = simple.mock(client, "joinRoom").callFn((rid, names) => { expect(rid).toEqual(newRoomId); expect(names).toBeDefined(); expect(names.length).toBe(1); expect(names[0]).toEqual(senderServer); }); AutojoinUpgradedRoomsMixin.setupOnClient(client); client.emit("room.archived", roomId, { content: { replacement_room: newRoomId, }, sender: `@someone:${senderServer}`, }); expect(joinSpy.callCount).toBe(1); }); it('should join rooms for appservice upgrades', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); const botIntent = appservice.botIntent; botIntent.ensureRegistered = () => { return null; }; const senderServer = "localhost:8448"; const roomId = "!test:example.org"; const newRoomId = "!new:example.org"; const joinedUserIds = ["@_prefix_aaa:example.org", "@_prefix_bbb:example.org", "@_bot_:example.org", "@unrelated:example.org"]; botIntent.underlyingClient.getJoinedRoomMembers = (rid) => { expect(rid).toEqual(roomId); return Promise.resolve(joinedUserIds); }; const joinSpy = simple.stub().callFn((rid, names) => { expect(rid).toEqual(newRoomId); expect(names).toBeDefined(); expect(names.length).toBe(1); expect(names[0]).toEqual(senderServer); return Promise.resolve(newRoomId); }); botIntent.underlyingClient.joinRoom = joinSpy as typeof botIntent.underlyingClient.joinRoom; const waitPromise = new Promise(((resolve, reject) => { const intentJoinSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(newRoomId); }); let calls = 0; appservice.getIntentForUserId = (uid) => { if (uid === "@_bot_:example.org") { return botIntent; } if (uid !== joinedUserIds[0] && uid !== joinedUserIds[1]) { throw new Error("Expected an appservice user ID, got " + uid); } if (++calls === 2) resolve(); return { joinRoom: intentJoinSpy, } as unknown as Intent; }; })); AutojoinUpgradedRoomsMixin.setupOnAppservice(appservice); appservice.emit("room.archived", roomId, { content: { replacement_room: newRoomId, }, sender: `@someone:${senderServer}`, }); await waitPromise; expect(joinSpy.callCount).toBe(1); }); }); ================================================ FILE: test/models/MatrixProfileTest.ts ================================================ import { MatrixProfile, MatrixProfileInfo, UserID } from "../../src"; describe("MatrixProfile", () => { it("should return the right fields", () => { const profile: MatrixProfileInfo = { displayname: "test", avatar_url: "mxc://example.org/abc123", }; const userId = new UserID("@alice:example.org"); const obj = new MatrixProfile(userId.toString(), profile); expect(obj.displayName).toEqual(profile.displayname); expect(obj.avatarUrl).toEqual(profile.avatar_url); expect(obj.mention).toBeDefined(); }); it("should return the localpart if there's no display name", () => { const profile: MatrixProfileInfo = { //displayname: "test", avatar_url: "mxc://example.org/abc123", }; const userId = new UserID("@alice:example.org"); const obj = new MatrixProfile(userId.toString(), profile); expect(obj.displayName).toEqual(userId.localpart); expect(obj.avatarUrl).toEqual(profile.avatar_url); expect(obj.mention).toBeDefined(); }); it("should convert empty avatar URLs to null", () => { const profile: MatrixProfileInfo = { displayname: "test", avatar_url: "", }; const userId = new UserID("@alice:example.org"); const obj = new MatrixProfile(userId.toString(), profile); expect(obj.displayName).toEqual(profile.displayname); expect(obj.avatarUrl).toEqual(null); expect(obj.mention).toBeDefined(); }); }); ================================================ FILE: test/models/PresenceTest.ts ================================================ import { Presence, PresenceEventContent } from "../../src"; describe("Presence", () => { it("should return the right fields", () => { const presence: PresenceEventContent = { status_msg: "Testing", currently_active: true, presence: "online", last_active_ago: 1234, }; const obj = new Presence(presence); expect(obj.currentlyActive).toEqual(presence.currently_active); expect(obj.statusMessage).toEqual(presence.status_msg); expect(obj.lastActiveAgo).toEqual(presence.last_active_ago); expect(obj.state).toEqual(presence.presence); }); }); ================================================ FILE: test/models/SpacesTest.ts ================================================ import * as simple from "simple-mock"; import { Space } from "../../src"; import { createTestClient } from "../TestUtils"; describe('Space', () => { describe('createChildSpace', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const via = 'example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const childRoomId = "!child:example.org"; const createOpts = { name: "TEST SPACE", topic: "This is a topic", localpart: "my-space", isPublic: true, }; const childEvContent = { via: [via], }; const createSpy = simple.spy(async (opts) => { expect(opts).toMatchObject(createOpts); return new Space(childRoomId, client); }); client.createSpace = createSpy; const calledFor = []; const expectedCalledFor = ["m.space.child"]; const stateEventSpy = simple.spy(async (roomId, type, stateKey, content) => { calledFor.push(type); if (type === "m.space.child") { expect(stateKey).toBe(childRoomId); expect(content).toMatchObject(childEvContent); expect(roomId).toBe(parentRoomId); return "$void"; } else { throw new Error("unexpected event"); } }); client.sendStateEvent = stateEventSpy as typeof client.sendStateEvent; const parent = new Space(parentRoomId, client); const child = await parent.createChildSpace(createOpts); expect(child).toBeDefined(); expect(child.roomId).toBe(childRoomId); expect(child.client).toBe(client); expect(createSpy.callCount).toBe(1); expect(stateEventSpy.callCount).toBe(1); expect(calledFor).toMatchObject(expectedCalledFor); }); }); describe('addChildSpace', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const via = 'example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const childRoomId = "!child:example.org"; const createOpts = { name: "TEST SPACE", topic: "This is a topic", localpart: "my-space", isPublic: true, }; const childEvContent = { via: [via], }; const createSpy = simple.spy(async (opts) => { expect(opts).toMatchObject(createOpts); return new Space(childRoomId, client); }); client.createSpace = createSpy; const calledFor = []; const expectedCalledFor = ["m.space.child"]; const stateEventSpy = simple.spy(async (roomId, type, stateKey, content) => { calledFor.push(type); if (type === "m.space.child") { expect(stateKey).toBe(childRoomId); expect(content).toMatchObject(childEvContent); expect(roomId).toBe(parentRoomId); return "$void"; } else { throw new Error("unexpected event"); } }); client.sendStateEvent = stateEventSpy as typeof client.sendStateEvent; const child = new Space(childRoomId, client); const parent = new Space(parentRoomId, client); await parent.addChildSpace(child); expect(createSpy.callCount).toBe(0); expect(stateEventSpy.callCount).toBe(1); expect(calledFor).toMatchObject(expectedCalledFor); }); }); describe('addChildRoom', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const via = 'example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const childRoomId = "!child:example.org"; const createOpts = { name: "TEST SPACE", topic: "This is a topic", localpart: "my-space", isPublic: true, }; const childEvContent = { via: [via], }; const createSpy = simple.spy(async (opts) => { expect(opts).toMatchObject(createOpts); return new Space(childRoomId, client); }); client.createSpace = createSpy; const calledFor = []; const expectedCalledFor = ["m.space.child"]; const stateEventSpy = simple.spy(async (roomId, type, stateKey, content) => { calledFor.push(type); if (type === "m.space.child") { expect(stateKey).toBe(childRoomId); expect(content).toMatchObject(childEvContent); expect(roomId).toBe(parentRoomId); return "$void"; } else { throw new Error("unexpected event"); } }); client.sendStateEvent = stateEventSpy as typeof client.sendStateEvent; const parent = new Space(parentRoomId, client); await parent.addChildRoom(childRoomId); expect(createSpy.callCount).toBe(0); expect(stateEventSpy.callCount).toBe(1); expect(calledFor).toMatchObject(expectedCalledFor); }); }); describe('removeChildSpace', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const via = 'example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const childRoomId = "!child:example.org"; const childEvContent = {}; const stateEventSpy = simple.spy(async (roomId, type, stateKey, content) => { expect(type).toBe("m.space.child"); expect(stateKey).toBe(childRoomId); expect(content).toMatchObject(childEvContent); expect(roomId).toBe(parentRoomId); return "$void"; }); client.sendStateEvent = stateEventSpy as typeof client.sendStateEvent; const child = new Space(childRoomId, client); const parent = new Space(parentRoomId, client); await parent.removeChildSpace(child); expect(stateEventSpy.callCount).toBe(1); }); }); describe('removeChildRoom', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const via = 'example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const childRoomId = "!child:example.org"; const childEvContent = {}; const stateEventSpy = simple.spy(async (roomId, type, stateKey, content) => { expect(type).toBe("m.space.child"); expect(stateKey).toBe(childRoomId); expect(content).toMatchObject(childEvContent); expect(roomId).toBe(parentRoomId); return "$void"; }); client.sendStateEvent = stateEventSpy as typeof client.sendStateEvent; const parent = new Space(parentRoomId, client); await parent.removeChildRoom(childRoomId); expect(stateEventSpy.callCount).toBe(1); }); }); describe('getChildEntities', () => { it('should return the viable child rooms', async () => { const { client } = createTestClient(); const via = 'example.org'; const via2 = '2.example.org'; (client).userId = `@alice:${via}`; const parentRoomId = "!parent:example.org"; const expectedRoomIds = ["!room2:example.org", "!room4:example.org"]; const stateEvents = [ { type: "m.room.create", content: { type: 'm.space' }, state_key: "" }, { type: "m.space.child", content: { suggested: true, via: [via] }, state_key: "!room1:example.org" }, { type: "m.space.child", content: { suggested: false, via: [via] }, state_key: expectedRoomIds[0] }, { type: "m.space.child", content: { suggested: true, via: [via2] }, state_key: "!room3:example.org" }, { type: "m.space.child", content: { suggested: false, via: [via2] }, state_key: expectedRoomIds[1] }, ]; client.getRoomState = async (roomId) => { expect(roomId).toBe(parentRoomId); return stateEvents; }; const parent = new Space(parentRoomId, client); const children = await parent.getChildEntities(); expect(Object.keys(children).length).toBe(4); expect(children["!room1:example.org"]).toBeDefined(); expect(children["!room1:example.org"].content).toMatchObject(stateEvents[1].content); expect(children["!room2:example.org"]).toBeDefined(); expect(children["!room2:example.org"].content).toMatchObject(stateEvents[2].content); expect(children["!room3:example.org"]).toBeDefined(); expect(children["!room3:example.org"].content).toMatchObject(stateEvents[3].content); expect(children["!room4:example.org"]).toBeDefined(); expect(children["!room4:example.org"].content).toMatchObject(stateEvents[4].content); }); }); describe('inviteUser', () => { it('should call the right endpoint', async () => { const { client } = createTestClient(); const parentRoomId = "!parent:example.org"; const targetUserId = "@alice:example.org"; client.inviteUser = async (userId: string, roomId: string) => { expect(userId).toBe(targetUserId); expect(roomId).toBe(roomId); return {}; }; const space = new Space(parentRoomId, client); await space.inviteUser(targetUserId); }); }); }); ================================================ FILE: test/models/events/AliasesEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { AliasesEvent } from "../../../src"; import { expectArrayEquals } from "../../TestUtils"; describe("AliasesEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev['state_key'] = 'example.org'; ev.content['aliases'] = ['#one:example.org', '#two:example.org']; const obj = new AliasesEvent(ev); expect(obj.forDomain).toEqual(ev['state_key']); expectArrayEquals(ev.content['aliases'], obj.aliases); }); }); ================================================ FILE: test/models/events/CanonicalAliasEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { CanonicalAliasEvent } from "../../../src"; describe("CanonicalAliasEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['alias'] = '#one:example.org'; const obj = new CanonicalAliasEvent(ev); expect(obj.aliases).toEqual(ev.content['alias']); }); }); ================================================ FILE: test/models/events/CreateEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { CreateEvent } from "../../../src"; describe("CreateEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['creator'] = '@bob:example.org'; ev.content['m.federate'] = false; ev.content['room_version'] = '4'; const obj = new CreateEvent(ev); expect(obj.creator).toEqual(ev.content['creator']); expect(obj.federated).toEqual(ev.content['m.federate']); expect(obj.version).toEqual(ev.content['room_version']); }); it("should default to the sender if the creator is not specified", () => { const ev = createMinimalEvent(); const obj = new CreateEvent(ev); expect(obj.creator).toEqual(ev.sender); }); it("should default to room version 1", () => { const ev = createMinimalEvent(); const obj = new CreateEvent(ev); expect(obj.version).toEqual("1"); }); it("should assume the room is federated", () => { const ev = createMinimalEvent(); const obj = new CreateEvent(ev); expect(obj.federated).toEqual(true); }); }); ================================================ FILE: test/models/events/EncryptedRoomEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { EncryptedRoomEvent, RoomEncryptionAlgorithm } from "../../../src"; describe("EncryptedRoomEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['algorithm'] = RoomEncryptionAlgorithm.MegolmV1AesSha2; ev.content['rotation_period_ms'] = 12; ev.content['rotation_period_msgs'] = 14; const obj = new EncryptedRoomEvent(ev); expect(obj.algorithm).toEqual(ev.content['algorithm']); expect(obj.megolmProperties).toBe(ev.content); // XXX: implementation detail that we know about }); }); ================================================ FILE: test/models/events/EncryptionEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { EncryptionEvent, RoomEncryptionAlgorithm } from "../../../src"; describe("EncryptionEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['algorithm'] = RoomEncryptionAlgorithm.MegolmV1AesSha2; ev.content['rotation_period_ms'] = 12; ev.content['rotation_period_msgs'] = 14; const obj = new EncryptionEvent(ev); expect(obj.algorithm).toEqual(ev.content['algorithm']); expect(obj.rotationPeriodMs).toEqual(ev.content['rotation_period_ms']); expect(obj.rotationPeriodMessages).toEqual(ev.content['rotation_period_msgs']); }); it("should default to a rotation period of 1 week", () => { const ev = createMinimalEvent(); const obj = new EncryptionEvent(ev); expect(obj.rotationPeriodMs).toEqual(604800000); // 1 week }); it("should default to a rotation period of 100 messages", () => { const ev = createMinimalEvent(); const obj = new EncryptionEvent(ev); expect(obj.rotationPeriodMessages).toEqual(100); }); }); ================================================ FILE: test/models/events/EventTest.ts ================================================ import { MatrixEvent } from "../../../src"; export function createMinimalEvent(content: any = { hello: "world" }) { return { sender: "@alice:example.org", type: "org.example.test", content: { ...content }, }; } describe("MatrixEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); const obj = new MatrixEvent(ev); expect(obj.sender).toEqual(ev.sender); expect(obj.type).toEqual(ev.type); expect(obj.content).toMatchObject(ev.content); expect(obj.raw).toBe(ev); }); }); ================================================ FILE: test/models/events/JoinRulesEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { JoinRulesEvent } from "../../../src"; describe("JoinRulesEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['join_rule'] = 'private'; const obj = new JoinRulesEvent(ev); expect(obj.rule).toEqual(ev.content['join_rule']); }); }); ================================================ FILE: test/models/events/MembershipEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { MembershipEvent } from "../../../src"; describe("MembershipEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev['state_key'] = '@bob:example.org'; ev.content['membership'] = 'join'; const obj = new MembershipEvent(ev); expect(obj.membershipFor).toEqual(ev['state_key']); expect(obj.membership).toEqual(ev.content['membership']); expect(obj.ownMembership).toEqual(false); expect(obj.effectiveMembership).toEqual("join"); }); it("should return true for ownMembership when required", () => { const ev = createMinimalEvent(); ev['state_key'] = ev['sender']; ev.content['membership'] = 'join'; const obj = new MembershipEvent(ev); expect(obj.membershipFor).toEqual(ev['state_key']); expect(obj.membership).toEqual(ev.content['membership']); expect(obj.ownMembership).toEqual(true); }); it("should return an effective membership of join for joins", () => { const ev = createMinimalEvent(); ev['state_key'] = '@bob:example.org'; ev.content['membership'] = 'join'; const obj = new MembershipEvent(ev); expect(obj.effectiveMembership).toEqual("join"); }); it("should return an effective membership of invite for invites", () => { const ev = createMinimalEvent(); ev['state_key'] = '@bob:example.org'; ev.content['membership'] = 'invite'; const obj = new MembershipEvent(ev); expect(obj.effectiveMembership).toEqual("invite"); }); it("should return an effective membership of leave for parts", () => { const ev = createMinimalEvent(); ev['state_key'] = ev['sender']; ev.content['membership'] = 'leave'; const obj = new MembershipEvent(ev); expect(obj.effectiveMembership).toEqual("leave"); }); it("should return an effective membership of leave for kicks", () => { const ev = createMinimalEvent(); ev['state_key'] = '@bob-otherperson:example.org'; ev.content['membership'] = 'leave'; const obj = new MembershipEvent(ev); expect(obj.effectiveMembership).toEqual("leave"); }); it("should return an effective membership of leave for bans", () => { const ev = createMinimalEvent(); ev['state_key'] = '@bob:example.org'; ev.content['membership'] = 'ban'; const obj = new MembershipEvent(ev); expect(obj.effectiveMembership).toEqual("leave"); }); }); ================================================ FILE: test/models/events/MessageEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { EventRedactedError, MessageEvent, MessageEventContent } from "../../../src"; describe("MessageEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['body'] = 'hello world'; ev.content['msgtype'] = 'm.notice'; const obj = new MessageEvent(ev); expect(obj.isRedacted).toEqual(false); expect(obj.messageType).toEqual(ev.content['msgtype']); expect(obj.textBody).toEqual(ev.content['body']); }); it("should return the right fields when redacted", () => { const ev = createMinimalEvent(); // ev.content['body'] = 'hello world'; // missing counts as redacted // ev.content['msgtype'] = 'm.notice'; // missing counts as redacted const obj = new MessageEvent(ev); expect(obj.isRedacted).toEqual(true); try { console.log(obj.messageType); // eslint-disable-line no-console // noinspection ExceptionCaughtLocallyJS throw new Error("Expected a throw but there was none"); } catch (e) { if (!(e instanceof EventRedactedError)) { throw new Error("Wrong error thrown"); } // else valid error } try { console.log(obj.textBody); // eslint-disable-line no-console // noinspection ExceptionCaughtLocallyJS throw new Error("Expected a throw but there was none"); } catch (e) { if (!(e instanceof EventRedactedError)) { throw new Error("Wrong error thrown"); } // else valid error } }); }); ================================================ FILE: test/models/events/PinnedEventsEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { expectArrayEquals } from "../../TestUtils"; import { PinnedEventsEvent } from "../../../src"; describe("PinnedEventsEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['pinned'] = ['$one:example.org', '$two:example.org']; const obj = new PinnedEventsEvent(ev); expectArrayEquals(ev.content['pinned'], obj.pinnedEventIds); }); }); ================================================ FILE: test/models/events/PowerLevelsEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { PowerLevelsEvent } from "../../../src"; describe("PowerLevelsEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['ban'] = 75; ev.content['events_default'] = 76; ev.content['invite'] = 77; ev.content['kick'] = 78; ev.content['redact'] = 79; ev.content['state_default'] = 80; ev.content['users_default'] = 81; ev.content['notifications'] = { room: 82 }; const obj = new PowerLevelsEvent(ev); expect(obj.banLevel).toEqual(ev.content['ban']); expect(obj.inviteLevel).toEqual(ev.content['invite']); expect(obj.kickLevel).toEqual(ev.content['kick']); expect(obj.redactLevel).toEqual(ev.content['redact']); expect(obj.notifyWholeRoomLevel).toEqual(ev.content['notifications']['room']); expect(obj.defaultUserLevel).toEqual(ev.content['users_default']); expect(obj.defaultStateEventLevel).toEqual(ev.content['state_default']); expect(obj.defaultEventLevel).toEqual(ev.content['events_default']); }); it("should return the default values if they aren't specified", () => { const ev = createMinimalEvent(); const obj = new PowerLevelsEvent(ev); expect(obj.banLevel).toEqual(50); expect(obj.inviteLevel).toEqual(50); expect(obj.kickLevel).toEqual(50); expect(obj.redactLevel).toEqual(50); expect(obj.notifyWholeRoomLevel).toEqual(50); expect(obj.defaultUserLevel).toEqual(0); expect(obj.defaultStateEventLevel).toEqual(50); expect(obj.defaultEventLevel).toEqual(50); }); }); ================================================ FILE: test/models/events/RedactionEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { RedactionEvent } from "../../../src"; import { expectArrayEquals } from "../../TestUtils"; describe("RedactionEvent", () => { it("should support CS-v2 format redactions", () => { const ev = createMinimalEvent(); ev['redacts'] = '$example'; const obj = new RedactionEvent(ev); expect(obj.redactsEventId).toEqual(ev['redacts']); expectArrayEquals([ev['redacts']], obj.redactsEventIds); }); it("should support MSC2174 format redactions", () => { const ev = createMinimalEvent(); ev.content['redacts'] = '$example'; const obj = new RedactionEvent(ev); expect(obj.redactsEventId).toEqual(ev.content['redacts']); expectArrayEquals([ev.content['redacts']], obj.redactsEventIds); }); it("should support MSC2244 format redactions", () => { const ev = createMinimalEvent(); ev.content['redacts'] = ['$example', '$another']; const obj = new RedactionEvent(ev); expect(obj.redactsEventId).toEqual(ev.content['redacts'][0]); expectArrayEquals(ev.content['redacts'], obj.redactsEventIds); }); }); ================================================ FILE: test/models/events/RoomAvatarEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { RoomAvatarEvent } from "../../../src"; describe("RoomAvatarEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['url'] = 'mxc://example.org/abc123'; const obj = new RoomAvatarEvent(ev); expect(obj.avatarUrl).toEqual(ev.content['url']); }); }); ================================================ FILE: test/models/events/RoomNameEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { RoomNameEvent } from "../../../src"; describe("RoomNameEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['name'] = '#one:example.org'; const obj = new RoomNameEvent(ev); expect(obj.name).toEqual(ev.content['name']); }); }); ================================================ FILE: test/models/events/RoomTopicEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { RoomTopicEvent } from "../../../src"; describe("RoomTopicEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['topic'] = '#one:example.org'; const obj = new RoomTopicEvent(ev); expect(obj.topic).toEqual(ev.content['topic']); }); }); ================================================ FILE: test/models/events/SpaceChildEventTest.ts ================================================ import { createMinimalEvent } from "./EventTest"; import { SpaceChildEvent } from "../../../src"; describe("SpaceChildEvent", () => { it("should return the right fields", () => { const ev = createMinimalEvent(); ev.content['via'] = ['example.org']; ev.content['order'] = 'test'; ev.content['suggested'] = true; ev['state_key'] = '!room:example.org'; const obj = new SpaceChildEvent(ev); expect(obj.viaServers).toEqual(ev.content['via']); expect(obj.order).toEqual(ev.content['order']); expect(obj.suggested).toEqual(ev.content['suggested']); expect(obj.entityRoomId).toEqual(ev['state_key']); expect(obj.isActiveChild).toEqual(true); }); it("should consider the child inactive when missing via", () => { const ev = createMinimalEvent(); //ev.content['via'] = ['example.org']; ev.content['order'] = 'test'; ev.content['suggested'] = true; ev['state_key'] = '!room:example.org'; const obj = new SpaceChildEvent(ev); expect(obj.viaServers).toBeFalsy(); expect(obj.isActiveChild).toEqual(false); }); it("should handle lack of optional fields", () => { const ev = createMinimalEvent(); ev.content['via'] = ['example.org']; ev['state_key'] = '!room:example.org'; const obj = new SpaceChildEvent(ev); expect(obj.viaServers).toEqual(ev.content['via']); expect(obj.isActiveChild).toEqual(true); expect(obj.order).toBeFalsy(); expect(obj.suggested).toBeFalsy(); }); }); ================================================ FILE: test/models/events/converterTest.ts ================================================ import { MembershipEvent, MessageEvent, MessageEventContent, RoomEvent, RoomEventContent, StateEvent, wrapRoomEvent, } from "../../../src"; import { createMinimalEvent } from "./EventTest"; import { expectInstanceOf } from "../../TestUtils"; describe("Event Converter", () => { it("should return generic room events", () => { const ev = createMinimalEvent(); const obj = wrapRoomEvent(ev); expect(obj.content).toBeDefined(); expectInstanceOf(RoomEvent, obj); }); it("should return generic state events", () => { const ev = createMinimalEvent(); ev['state_key'] = 'test'; const obj = >wrapRoomEvent(ev); expect(obj.stateKey).toEqual(ev['state_key']); expectInstanceOf(StateEvent, obj); }); it("should return membership events", () => { const ev = createMinimalEvent({ membership: "join" }); ev['state_key'] = 'test'; ev['type'] = 'm.room.member'; const obj = wrapRoomEvent(ev); expect(obj.membership).toEqual(ev['content']['membership']); expectInstanceOf(MembershipEvent, obj); }); it("should return message events", () => { const ev = createMinimalEvent({ msgtype: "m.text" }); ev['type'] = 'm.room.message'; const obj = >wrapRoomEvent(ev); expect(obj.messageType).toEqual(ev['content']['msgtype']); expectInstanceOf(MessageEvent, obj); }); }); ================================================ FILE: test/preprocessors/RichRepliesPreprocessorTest.ts ================================================ import * as simple from "simple-mock"; import { EventKind, RichRepliesPreprocessor } from "../../src"; import { createTestClient } from "../TestUtils"; describe('RichRepliesPreprocessor', () => { it('should only process room events', async () => { const processor = new RichRepliesPreprocessor(); const { client } = createTestClient(); const originalEventId = "$original:example.org"; const originalUserId = "@alice:example.org"; const originalPlainText = "Hello world, this is text"; const originalHtml = "Hello world, this is text"; const originalRoomId = "!somewhere:example.org"; const replyPlainText = "This is the reply"; const replyHtml = "This is the reply text"; const event1 = { content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": `> <${originalUserId}> ${originalPlainText}\n\n${replyPlainText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalUserId}
${originalHtml}
${replyHtml}`, }, }; const event2 = { content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": `> <${originalUserId}> ${originalPlainText}\n\n${replyPlainText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalUserId}
${originalHtml}
${replyHtml}`, }, }; let result = await processor.processEvent(event1, client, EventKind.EphemeralEvent); expect(result).toBeUndefined(); result = await processor.processEvent(event1, client); expect(result).toMatchObject({ mx_richreply: { wasLenient: false, parentEventId: originalEventId, fallbackPlainBody: `<${originalUserId}> ${originalPlainText}`, fallbackHtmlBody: originalHtml, fallbackSender: originalUserId, realEvent: null, }, content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": replyPlainText, "formatted_body": replyHtml, }, }); result = await processor.processEvent(event2, client, EventKind.RoomEvent); expect(result).toMatchObject({ mx_richreply: { wasLenient: false, parentEventId: originalEventId, fallbackPlainBody: `<${originalUserId}> ${originalPlainText}`, fallbackHtmlBody: originalHtml, fallbackSender: originalUserId, realEvent: null, }, content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": replyPlainText, "formatted_body": replyHtml, }, }); }); it('should parse single-line events', async () => { const { client } = createTestClient(); const originalEventId = "$original:example.org"; const originalUserId = "@alice:example.org"; const originalPlainText = "Hello world, this is text"; const originalHtml = "Hello world, this is text"; const originalRoomId = "!somewhere:example.org"; const replyPlainText = "This is the reply"; const replyHtml = "This is the reply text"; const event = { content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": `> <${originalUserId}> ${originalPlainText}\n\n${replyPlainText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalUserId}
${originalHtml}
${replyHtml}`, }, }; const processor = new RichRepliesPreprocessor(); const result = await processor.processEvent(event, client, EventKind.RoomEvent); expect(result).toMatchObject({ mx_richreply: { wasLenient: false, parentEventId: originalEventId, fallbackPlainBody: `<${originalUserId}> ${originalPlainText}`, fallbackHtmlBody: originalHtml, fallbackSender: originalUserId, realEvent: null, }, content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": replyPlainText, "formatted_body": replyHtml, }, }); }); it('should parse multi-line events', async () => { const { client } = createTestClient(); const originalEventId = "$original:example.org"; const originalUserId = "@alice:example.org"; const originalPlainText = "Hello world, this is text\nTesting line 2"; const originalHtml = "Hello world, this is text

Testing line 2

"; const originalRoomId = "!somewhere:example.org"; const replyPlainText = "This is the reply\nWith two lines"; const replyHtml = "This is the reply text

With two lines

"; const event = { content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": `> <${originalUserId}> ${originalPlainText.split('\n').join('\n> ')}\n\n${replyPlainText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalUserId}
${originalHtml}
${replyHtml}`, }, }; const processor = new RichRepliesPreprocessor(); const result = await processor.processEvent(event, client); expect(result).toMatchObject({ mx_richreply: { wasLenient: false, parentEventId: originalEventId, fallbackPlainBody: `<${originalUserId}> ${originalPlainText}`, fallbackHtmlBody: originalHtml, fallbackSender: originalUserId, realEvent: null, }, content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": replyPlainText, "formatted_body": replyHtml, }, }); }); it('should only support messages', () => { const processor = new RichRepliesPreprocessor(); const types = processor.getSupportedEventTypes(); expect(types.length).toBe(1); expect(types).toContain("m.room.message"); }); it('should fetch the real message when instructed', async () => { const { client } = createTestClient(); const realEvent = { hello: "world" }; const originalEventId = "$original:example.org"; const originalUserId = "@alice:example.org"; const originalPlainText = "Hello world, this is text\nTesting line 2"; const originalHtml = "Hello world, this is text

Testing line 2

"; const originalRoomId = "!somewhere:example.org"; const replyPlainText = "This is the reply\nWith two lines"; const replyHtml = "This is the reply text

With two lines

"; const roomId = "!abc123:example.org"; const event = { content: { "m.relates_to": { "m.in_reply_to": { event_id: originalEventId, }, }, "format": "org.matrix.custom.html", "body": `> <${originalUserId}> ${originalPlainText.split('\n').join('\n> ')}\n\n${replyPlainText}`, // eslint-disable-next-line max-len "formatted_body": `
In reply to ${originalUserId}
${originalHtml}
${replyHtml}`, }, // TODO: Pre-processors need to be able to support events without room_id set room_id: roomId, }; const getEventSpy = simple.mock(client, "getEvent").callFn((rid, evId) => { expect(rid).toEqual(roomId); expect(evId).toEqual(originalEventId); return realEvent; }); let processor; let result; processor = new RichRepliesPreprocessor(); result = await processor.processEvent(event, client); expect(getEventSpy.callCount).toBe(0); expect(result["mx_richreply"]["realEvent"]).toBeNull(); processor = new RichRepliesPreprocessor(true); result = await processor.processEvent(event, client); expect(getEventSpy.callCount).toBe(1); expect(result["mx_richreply"]["realEvent"]).toMatchObject(realEvent); }); it('should parse MSC3676 replies', async () => { const { client } = createTestClient(); const event = { content: { "m.relates_to": { "m.in_reply_to": { event_id: "$original", }, }, "body": "image.png", "msgtype": "m.image", "url": "mxc://example.org/irrelevant", }, }; const processor = new RichRepliesPreprocessor(); const result = await processor.processEvent(event, client); expect(result).toMatchObject({ mx_richreply: { wasLenient: true, parentEventId: event.content["m.relates_to"]["m.in_reply_to"].event_id, fallbackPlainBody: "", fallbackHtmlBody: "", fallbackSender: "", realEvent: null, }, content: event.content, }); }); }); ================================================ FILE: test/requestTest.ts ================================================ import { getRequestFn, setRequestFn } from "../src"; describe('request', () => { it('should return whatever request function was set', () => { const testFn = (() => null); setRequestFn(testFn); expect(getRequestFn()).toBe(testFn); }); }); ================================================ FILE: test/simple-validationTest.ts ================================================ import { validateSpaceOrderString } from "../src"; describe('validateSpaceOrderString', () => { it('should return true with valid identifiers', () => { expect(validateSpaceOrderString("hello")).toBe(true); expect(validateSpaceOrderString("h")).toBe(true); expect(validateSpaceOrderString("12345678901234567890123456789012345678901234567890")).toBe(true); expect(validateSpaceOrderString("TEst")).toBe(true); expect(validateSpaceOrderString("org.example.order_1")).toBe(true); expect(validateSpaceOrderString("org.example.order-beta")).toBe(true); }); it('should throw for undefined/null identifiers', () => { try { validateSpaceOrderString(null); validateSpaceOrderString(undefined); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toBe("order is not a string"); } }); it('should throw for not-string identifiers', () => { try { validateSpaceOrderString({}); validateSpaceOrderString(true); validateSpaceOrderString(false); validateSpaceOrderString([]); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toBe("order is not a string"); } }); it('should throw for too short of identifiers', () => { try { validateSpaceOrderString(""); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toBe("order cannot be empty"); } }); it('should throw for too long of identifiers', () => { try { validateSpaceOrderString("12345678901234567890123456789012345678901234567890-"); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toBe("order is more than 50 characters and is disallowed"); } }); it('should throw for improper identifiers', () => { try { validateSpaceOrderString("test😀"); validateSpaceOrderString("test\x07"); validateSpaceOrderString("test\x7F"); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); } catch (e) { expect(e.message).toBe("order contained characters outside the range of the spec."); } }); }); ================================================ FILE: test/storage/MemoryStorageProviderTest.ts ================================================ import { IFilterInfo, MemoryStorageProvider } from "../../src"; describe('MemoryStorageProvider', () => { it('should return the right sync token', async () => { const provider = new MemoryStorageProvider(); const value = "testing"; expect(await provider.getSyncToken()).toBeFalsy(); await provider.setSyncToken(value); expect(await provider.getSyncToken()).toEqual(value); }); it('should return the right filter object', async () => { const provider = new MemoryStorageProvider(); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; expect(await provider.getFilter()).toBeFalsy(); await provider.setFilter(value); expect(await provider.getFilter()).toMatchObject(value); }); it('should track registered users', async () => { const provider = new MemoryStorageProvider(); const userIdA = "@first:example.org"; const userIdB = "@second:example.org"; expect(await provider.isUserRegistered(userIdA)).toBeFalsy(); expect(await provider.isUserRegistered(userIdB)).toBeFalsy(); await provider.addRegisteredUser(userIdA); expect(await provider.isUserRegistered(userIdA)).toBeTruthy(); expect(await provider.isUserRegistered(userIdB)).toBeFalsy(); await provider.addRegisteredUser(userIdA); // duplicated to make sure it is safe to do so expect(await provider.isUserRegistered(userIdA)).toBeTruthy(); expect(await provider.isUserRegistered(userIdB)).toBeFalsy(); await provider.addRegisteredUser(userIdB); expect(await provider.isUserRegistered(userIdA)).toBeTruthy(); expect(await provider.isUserRegistered(userIdB)).toBeTruthy(); }); it('should track completed transactions', async () => { const provider = new MemoryStorageProvider(); const txnA = "@first:example.org"; const txnB = "@second:example.org"; expect(await provider.isTransactionCompleted(txnA)).toBeFalsy(); expect(await provider.isTransactionCompleted(txnB)).toBeFalsy(); await provider.setTransactionCompleted(txnA); expect(await provider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await provider.isTransactionCompleted(txnB)).toBeFalsy(); await provider.setTransactionCompleted(txnA); // duplicated to make sure it is safe to do so expect(await provider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await provider.isTransactionCompleted(txnB)).toBeFalsy(); await provider.setTransactionCompleted(txnB); expect(await provider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await provider.isTransactionCompleted(txnB)).toBeTruthy(); }); it('should track arbitrary key value pairs', async () => { const provider = new MemoryStorageProvider(); const key = "test"; const value = "example"; expect(await provider.readValue(key)).toBeFalsy(); await provider.storeValue(key, value); expect(await provider.readValue(key)).toEqual(value); }); describe('namespacing', () => { it('should return the right sync token', async () => { const provider = new MemoryStorageProvider(); const value = "testing"; const namespace = "@user:example.org"; const nsProvider = provider.storageForUser(namespace); expect(nsProvider).toBeDefined(); expect(await provider.getSyncToken()).toBeFalsy(); expect(await nsProvider.getSyncToken()).toBeFalsy(); await nsProvider.setSyncToken(value); expect(await provider.getSyncToken()).toBeFalsy(); expect(await nsProvider.getSyncToken()).toEqual(value); }); it('should return the right filter object', async () => { const provider = new MemoryStorageProvider(); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; const namespace = "@user:example.org"; const nsProvider = provider.storageForUser(namespace); expect(nsProvider).toBeDefined(); expect(await provider.getFilter()).toBeFalsy(); expect(await nsProvider.getFilter()).toBeFalsy(); await nsProvider.setFilter(value); expect(await provider.getFilter()).toBeFalsy(); expect(await nsProvider.getFilter()).toMatchObject(value); }); it('should track arbitrary key value pairs', async () => { const provider = new MemoryStorageProvider(); const key = "test"; const value = "example"; const namespace = "@user:example.org"; const nsProvider = provider.storageForUser(namespace); expect(nsProvider).toBeDefined(); expect(await provider.readValue(key)).toBeFalsy(); expect(await nsProvider.readValue(key)).toBeFalsy(); await nsProvider.storeValue(key, value); expect(await provider.readValue(key)).toBeFalsy(); expect(await nsProvider.readValue(key)).toEqual(value); }); }); }); ================================================ FILE: test/storage/SimpleFsStorageProviderTest.ts ================================================ import * as tmp from "tmp"; import { IFilterInfo, SimpleFsStorageProvider } from "../../src"; tmp.setGracefulCleanup(); function createSimpleFsStorageProvider(inMemory = false, maxMemTransactions = 20) { const tmpFile = tmp.fileSync(); const writeProvider = new SimpleFsStorageProvider(tmpFile.name, inMemory, maxMemTransactions); const readProviderFn = () => new SimpleFsStorageProvider(tmpFile.name, inMemory, maxMemTransactions); return { tmpFile, writeProvider, readProviderFn }; } describe('SimpleFsStorageProvider', () => { it('should return the right sync token', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const value = "testing"; expect(await writeProvider.getSyncToken()).toBeFalsy(); await writeProvider.setSyncToken(value); expect(await writeProvider.getSyncToken()).toEqual(value); expect(await readProviderFn().getSyncToken()).toEqual(value); }); it('should return the right filter object', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; expect(await writeProvider.getFilter()).toBeFalsy(); await writeProvider.setFilter(value); expect(await writeProvider.getFilter()).toMatchObject(value); expect(await readProviderFn().getFilter()).toMatchObject(value); }); it('should track registered users', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const userIdA = "@first:example.org"; const userIdB = "@second:example.org"; expect(await writeProvider.isUserRegistered(userIdA)).toBeFalsy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdA); expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdA); // duplicated to make sure it is safe to do so expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdB); expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeTruthy(); }); it('should track completed transactions', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const txnA = "@first:example.org"; const txnB = "@second:example.org"; expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); // duplicated to make sure it is safe to do so expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnB); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeTruthy(); }); it('should track a limited number of completed transactions in memory', async () => { const maxTransactions = 2; const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(true, maxTransactions); const txnA = "@first:example.org"; const txnB = "@second:example.org"; const txnC = "@third:example.org"; // The read provider results should always be falsey because the write provider // should not be writing to disk. expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); // duplicated to make sure it is safe to do so expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnB); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnC); expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); // No longer in memory expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); }); it('should track arbitrary key value pairs', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const key = "test"; const value = "testing"; expect(writeProvider.readValue(key)).toBeFalsy(); writeProvider.storeValue(key, value); expect(writeProvider.readValue(key)).toEqual(value); expect(readProviderFn().readValue(key)).toEqual(value); }); describe('namespacing', () => { it('should return the right sync token', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const value = "testing"; const namespace = "@user:example.org"; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await nsWriter.getSyncToken()).toBeFalsy(); await nsWriter.setSyncToken(value); expect(await nsWriter.getSyncToken()).toEqual(value); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await readProviderFn().storageForUser(namespace).getSyncToken()).toEqual(value); expect(await readProviderFn().getSyncToken()).toBeFalsy(); }); it('should return the right filter object', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; const namespace = "@user:example.org"; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await nsWriter.getFilter()).toBeFalsy(); await nsWriter.setFilter(value); expect(await nsWriter.getFilter()).toMatchObject(value); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await readProviderFn().storageForUser(namespace).getFilter()).toMatchObject(value); expect(await readProviderFn().getFilter()).toBeFalsy(); }); it('should track arbitrary key value pairs', async () => { const { writeProvider, readProviderFn } = createSimpleFsStorageProvider(); const key = "test"; const value = "testing"; const namespace = "@user:example.org"; const nsKey = `${namespace}_kv_${key}`; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await nsWriter.readValue(key)).toBeFalsy(); expect(await writeProvider.readValue(nsKey)).toBeFalsy(); await nsWriter.storeValue(key, value); expect(await nsWriter.readValue(key)).toEqual(value); expect(await writeProvider.readValue(nsKey)).toEqual(value); expect(await readProviderFn().storageForUser(namespace).readValue(key)).toEqual(value); expect(await readProviderFn().readValue(nsKey)).toEqual(value); }); }); }); ================================================ FILE: test/storage/SimplePostgresStorageProviderTest.ts ================================================ import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { IFilterInfo, SimplePostgresStorageProvider } from "../../src"; function createSimplePostgresStorageProvider(connectionString: string, inMemory = false, maxMemTransactions = 20) { const writeProvider = new SimplePostgresStorageProvider(connectionString, inMemory, maxMemTransactions); const readProviderFn = () => new SimplePostgresStorageProvider(connectionString, inMemory, maxMemTransactions); return { writeProvider, readProviderFn }; } describe('SimplePostgresStorageProvider', () => { let postgresContainer: StartedPostgreSqlContainer; beforeAll(async () => { postgresContainer = await new PostgreSqlContainer() .withLogConsumer(async s => { for await (const chunk of s) { console.log("[PSQL] " + Buffer.from(chunk).toString("utf-8")); // eslint-disable-line no-console } }) .withCommand(["postgres", "-c", "max_connections=1000"]) .start(); }, 5 * 60 * 1000); afterAll(async () => { await postgresContainer.stop(); }, 5 * 60 * 1000); it('should return the right sync token', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const value = "testing"; expect(await writeProvider.getSyncToken()).toBeFalsy(); await writeProvider.setSyncToken(value); expect(await writeProvider.getSyncToken()).toEqual(value); expect(await readProviderFn().getSyncToken()).toEqual(value); await writeProvider.setSyncToken(null); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await readProviderFn().getSyncToken()).toBeFalsy(); }); it('should return the right filter object', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; expect(await writeProvider.getFilter()).toBeFalsy(); await writeProvider.setFilter(value); expect(await writeProvider.getFilter()).toMatchObject(value); expect(await readProviderFn().getFilter()).toMatchObject(value); await writeProvider.setFilter(null); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await readProviderFn().getFilter()).toBeFalsy(); }); it('should track registered users', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const userIdA = "@first:example.org"; const userIdB = "@second:example.org"; expect(await writeProvider.isUserRegistered(userIdA)).toBeFalsy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdA); expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdA); // duplicated to make sure it is safe to do so expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeFalsy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeFalsy(); await writeProvider.addRegisteredUser(userIdB); expect(await writeProvider.isUserRegistered(userIdA)).toBeTruthy(); expect(await writeProvider.isUserRegistered(userIdB)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdA)).toBeTruthy(); expect(await readProviderFn().isUserRegistered(userIdB)).toBeTruthy(); }); it('should track completed transactions', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const txnA = "@first:example.org"; const txnB = "@second:example.org"; expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); // duplicated to make sure it is safe to do so expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnB); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeTruthy(); }); it('should track a limited number of completed transactions in memory', async () => { const maxTransactions = 2; const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri(), true, maxTransactions); const txnA = "@first:example.org"; const txnB = "@second:example.org"; const txnC = "@third:example.org"; // The read provider results should always be falsey because the write provider // should not be writing to disk. expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnA); // duplicated to make sure it is safe to do so expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeFalsy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnB); expect(await writeProvider.isTransactionCompleted(txnA)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); await writeProvider.setTransactionCompleted(txnC); expect(await writeProvider.isTransactionCompleted(txnA)).toBeFalsy(); // No longer in memory expect(await writeProvider.isTransactionCompleted(txnB)).toBeTruthy(); expect(await writeProvider.isTransactionCompleted(txnC)).toBeTruthy(); expect(await readProviderFn().isTransactionCompleted(txnA)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnB)).toBeFalsy(); expect(await readProviderFn().isTransactionCompleted(txnC)).toBeFalsy(); }); it('should track arbitrary key value pairs', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const key = "test"; const value = "testing"; expect(await writeProvider.readValue(key)).toBeFalsy(); await writeProvider.storeValue(key, value); expect(await writeProvider.readValue(key)).toEqual(value); expect(await readProviderFn().readValue(key)).toEqual(value); await writeProvider.storeValue(key, null); expect(await writeProvider.readValue(key)).toBeFalsy(); expect(await readProviderFn().readValue(key)).toBeFalsy(); }); describe('namespacing', () => { it('should return the right sync token', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const value = "testing"; const namespace = "@user:example.org"; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await nsWriter.getSyncToken()).toBeFalsy(); await nsWriter.setSyncToken(value); expect(await nsWriter.getSyncToken()).toEqual(value); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await readProviderFn().storageForUser(namespace).getSyncToken()).toEqual(value); expect(await readProviderFn().getSyncToken()).toBeFalsy(); await nsWriter.setSyncToken(null); expect(await writeProvider.getSyncToken()).toBeFalsy(); expect(await nsWriter.getSyncToken()).toBeFalsy(); }); it('should return the right filter object', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const value: IFilterInfo = { id: 12, filter: { hello: "world" } }; const namespace = "@user:example.org"; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await nsWriter.getFilter()).toBeFalsy(); await nsWriter.setFilter(value); expect(await nsWriter.getFilter()).toMatchObject(value); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await readProviderFn().storageForUser(namespace).getFilter()).toMatchObject(value); expect(await readProviderFn().getFilter()).toBeFalsy(); await nsWriter.setFilter(null); expect(await writeProvider.getFilter()).toBeFalsy(); expect(await nsWriter.getFilter()).toBeFalsy(); }); it('should track arbitrary key value pairs', async () => { const { writeProvider, readProviderFn } = createSimplePostgresStorageProvider(postgresContainer.getConnectionUri()); const key = "test"; const value = "testing"; const namespace = "@user:example.org"; const nsKey = `${namespace}_internal_kv_${key}`; const nsWriter = writeProvider.storageForUser(namespace); expect(nsWriter).toBeDefined(); expect(await nsWriter.readValue(key)).toBeFalsy(); expect(await writeProvider.readValue(nsKey)).toBeFalsy(); await nsWriter.storeValue(key, value); expect(await nsWriter.readValue(key)).toEqual(value); expect(await writeProvider.readValue(nsKey)).toEqual(value); expect(await readProviderFn().storageForUser(namespace).readValue(key)).toEqual(value); expect(await readProviderFn().readValue(nsKey)).toEqual(value); await nsWriter.storeValue(key, null); expect(await nsWriter.readValue(key)).toBeFalsy(); expect(await writeProvider.readValue(nsKey)).toBeFalsy(); }); }); }); ================================================ FILE: test/strategies/AppserviceJoinRoomStrategyTest.ts ================================================ import * as simple from "simple-mock"; import { Appservice, AppserviceJoinRoomStrategy, IJoinRoomStrategy } from "../../src"; describe('AppserviceJoinRoomStrategy', () => { it('should be able to join the room normally', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const underlyingSpy = simple.stub().callFn((rid, uid, apiCall) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); expect(apiCall).toBeDefined(); return Promise.resolve(); }); const underlyingStrategy = { joinRoom: underlyingSpy } as unknown as IJoinRoomStrategy; const strategy = new AppserviceJoinRoomStrategy(underlyingStrategy, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); return Promise.resolve("!void:example.org"); }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(1); expect(underlyingSpy.callCount).toBe(0); }); it('should call the underlying strategy after the first failure', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return Promise.resolve(); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const underlyingSpy = simple.stub().callFn((rid, uid, apiCall) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); expect(apiCall).toBeDefined(); return Promise.resolve(); }); const underlyingStrategy = { joinRoom: underlyingSpy } as unknown as IJoinRoomStrategy; const strategy = new AppserviceJoinRoomStrategy(underlyingStrategy, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(1); expect(underlyingSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); }); it('should not invite the bot user if the bot user is joining', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return Promise.resolve(); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@_bot_:example.org"; const underlyingSpy = simple.stub().callFn((rid, uid, apiCall) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); expect(apiCall).toBeDefined(); return Promise.resolve(); }); const underlyingStrategy = { joinRoom: underlyingSpy } as unknown as IJoinRoomStrategy; const strategy = new AppserviceJoinRoomStrategy(underlyingStrategy, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(1); expect(underlyingSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(0); }); it('should call the API twice when there is no strategy', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return Promise.resolve(); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const strategy = new AppserviceJoinRoomStrategy(null, appservice); let attempt = 0; const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); if (attempt++ === 0) { throw new Error("Simulated failure"); } else return Promise.resolve("!void:example.org"); }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(2); expect(inviteSpy.callCount).toBe(1); }); it('should call the API once when there is no strategy for the bot user', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return Promise.resolve(); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@_bot_:example.org"; const strategy = new AppserviceJoinRoomStrategy(null, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); try { await strategy.joinRoom(roomId, userId, apiCallSpy); // noinspection ExceptionCaughtLocallyJS throw new Error("Join succeeded when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(apiCallSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(0); }); it('should fail if the underlying strategy fails', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); return Promise.resolve(); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const underlyingSpy = simple.stub().callFn((rid, uid, apiCall) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); expect(apiCall).toBeDefined(); throw new Error("Simulated failure 2"); }); const underlyingStrategy = { joinRoom: underlyingSpy }; const strategy = new AppserviceJoinRoomStrategy(underlyingStrategy, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); try { await strategy.joinRoom(roomId, userId, apiCallSpy); // noinspection ExceptionCaughtLocallyJS throw new Error("Join succeeded when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure 2"); } expect(apiCallSpy.callCount).toBe(1); expect(underlyingSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); }); it('should handle invite failures', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); throw new Error("Simulated invite error"); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const strategy = new AppserviceJoinRoomStrategy(null, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); try { await strategy.joinRoom(roomId, userId, apiCallSpy); // noinspection ExceptionCaughtLocallyJS throw new Error("Join succeeded when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated invite error"); } expect(apiCallSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); }); it('should pass to the underlying strategy on invite failures', async () => { const appservice = new Appservice({ port: 0, bindAddress: '127.0.0.1', homeserverName: 'example.org', homeserverUrl: 'https://localhost', registration: { as_token: "", hs_token: "", sender_localpart: "_bot_", namespaces: { users: [{ exclusive: true, regex: "@_prefix_.*:.+" }], rooms: [], aliases: [], }, }, }); appservice.botIntent.ensureRegistered = () => { return null; }; appservice.botIntent.underlyingClient.resolveRoom = async (rid) => { return roomId; }; const inviteSpy = simple.stub().callFn((uid, rid) => { expect(uid).toEqual(userId); expect(rid).toEqual(roomId); throw new Error("Simulated invite error"); }); appservice.botIntent.underlyingClient.inviteUser = inviteSpy; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const underlyingSpy = simple.stub().callFn((rid, uid, apiCall) => { expect(rid).toEqual(roomId); expect(uid).toEqual(userId); expect(apiCall).toBeDefined(); throw new Error("Simulated failure 2"); }); const underlyingStrategy = { joinRoom: underlyingSpy }; const strategy = new AppserviceJoinRoomStrategy(underlyingStrategy, appservice); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); try { await strategy.joinRoom(roomId, userId, apiCallSpy); // noinspection ExceptionCaughtLocallyJS throw new Error("Join succeeded when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure 2"); } expect(apiCallSpy.callCount).toBe(1); expect(underlyingSpy.callCount).toBe(1); expect(inviteSpy.callCount).toBe(1); }); }); ================================================ FILE: test/strategies/JoinRoomStrategyTest.ts ================================================ import * as simple from "simple-mock"; import { SimpleRetryJoinStrategy } from "../../src"; describe('SimpleRetryJoinStrategy', () => { it('should retry joins when they fail', async () => { const strategy = new SimpleRetryJoinStrategy(); const schedule = [0, 10, 20]; (strategy).schedule = schedule; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; let attempt = 0; const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); attempt++; if (attempt === schedule.length) { return Promise.resolve("!void:example.org"); } else { throw new Error("Simulated failure"); } }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(schedule.length); }); it('should retry joins on a schedule', async () => { const strategy = new SimpleRetryJoinStrategy(); const schedule = [0, 500, 750]; (strategy).schedule = schedule; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const tolerance = 100; let attempt = 0; let joinStarted = new Date().getTime(); const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); const deltaTime = (new Date().getTime()) - joinStarted; joinStarted = new Date().getTime(); const expectedVal = schedule[attempt]; expect(deltaTime).toBeGreaterThan(expectedVal - tolerance); expect(deltaTime).toBeLessThan(expectedVal + tolerance); attempt++; if (attempt === schedule.length) { return Promise.resolve("!void:example.org"); } else { throw new Error("Simulated failure"); } }); await strategy.joinRoom(roomId, userId, apiCallSpy); expect(apiCallSpy.callCount).toBe(schedule.length); }); it('should fail if all attempts fail', async () => { const strategy = new SimpleRetryJoinStrategy(); const schedule = [0, 10, 20]; (strategy).schedule = schedule; const roomId = "!somewhere:example.org"; const userId = "@someone:example.org"; const apiCallSpy = simple.stub().callFn((rid) => { expect(rid).toEqual(roomId); throw new Error("Simulated failure"); }); try { await strategy.joinRoom(roomId, userId, apiCallSpy); // noinspection ExceptionCaughtLocallyJS throw new Error("Join succeeded when it should have failed"); } catch (e) { expect(e.message).toEqual("Simulated failure"); } expect(apiCallSpy.callCount).toBe(schedule.length); }); }); ================================================ FILE: tsconfig-examples.json ================================================ { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", "target": "es2020", "noImplicitAny": false, "sourceMap": false, "outDir": "./lib", "declaration": true, "types": [ "node" ] }, "include": [ "./examples/**/*" ] } ================================================ FILE: tsconfig-release.json ================================================ { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", "target": "es2020", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", "declaration": true, "types": [ "node", "express", "jest" ] }, "include": [ "./src/**/*" ] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "module": "commonjs", "moduleResolution": "node", "target": "es2020", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", "declaration": true, "types": [ "node", "express", "jest" ] }, "include": [ "./src/**/*", "./test/**/*" ] }